From 4989cf6c72f24656a78002399646aa8a6ea4d405 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 13 Oct 2025 18:00:54 +0200 Subject: [PATCH 01/51] chore: old diagram deprication --- Website/app/diagram/page.tsx | 2 +- .../components/diagramview/DiagramCanvas.tsx | 77 -- .../diagramview/DiagramControls.tsx | 113 -- .../components/diagramview/DiagramRenderer.ts | 161 --- .../diagramview/DiagramResetButton.tsx | 26 - .../components/diagramview/DiagramView.tsx | 917 +------------ .../diagramview/GridLayoutManager.ts | 59 +- .../diagramview/SidebarDiagramView.tsx | 365 +---- .../diagramview/ZoomCoordinateIndicator.tsx | 39 - .../diagramview/elements/EntityAttribute.ts | 48 - .../diagramview/elements/EntityBody.ts | 39 - .../diagramview/elements/EntityElement.ts | 191 --- .../elements/SimpleEntityElement.ts | 147 --- .../diagramview/elements/SquareElement.ts | 290 ---- .../diagramview/elements/SquareElementView.ts | 42 - .../diagramview/elements/TextElement.ts | 143 -- .../diagramview/panes/AddAttributePane.tsx | 164 --- .../diagramview/panes/AddEntityPane.tsx | 259 ---- .../diagramview/panes/AddGroupPane.tsx | 187 --- .../panes/AttributeSelectionPanel.tsx | 96 -- .../diagramview/panes/EntityActionsPane.tsx | 319 ----- .../diagramview/panes/LinkPropertiesPane.tsx | 237 ---- .../diagramview/panes/ResetToGroupPane.tsx | 100 -- .../panes/SquarePropertiesPane.tsx | 263 ---- .../diagramview/panes/TextPropertiesPane.tsx | 307 ----- Website/components/diagramview/panes/index.ts | 14 - .../renderers/DetailedDiagramRender.ts | 208 --- .../renderers/SimpleDiagramRender.ts | 158 --- .../diagramview/shared/DiagramConstants.ts | 30 - Website/components/shared/Layout.tsx | 5 +- Website/contexts/DiagramViewContext.tsx | 77 +- Website/hooks/useDiagram.ts | 1169 ----------------- 32 files changed, 81 insertions(+), 6171 deletions(-) delete mode 100644 Website/components/diagramview/DiagramCanvas.tsx delete mode 100644 Website/components/diagramview/DiagramControls.tsx delete mode 100644 Website/components/diagramview/DiagramRenderer.ts delete mode 100644 Website/components/diagramview/DiagramResetButton.tsx delete mode 100644 Website/components/diagramview/ZoomCoordinateIndicator.tsx delete mode 100644 Website/components/diagramview/elements/EntityAttribute.ts delete mode 100644 Website/components/diagramview/elements/EntityBody.ts delete mode 100644 Website/components/diagramview/elements/EntityElement.ts delete mode 100644 Website/components/diagramview/elements/SimpleEntityElement.ts delete mode 100644 Website/components/diagramview/elements/SquareElement.ts delete mode 100644 Website/components/diagramview/elements/SquareElementView.ts delete mode 100644 Website/components/diagramview/elements/TextElement.ts delete mode 100644 Website/components/diagramview/panes/AddAttributePane.tsx delete mode 100644 Website/components/diagramview/panes/AddEntityPane.tsx delete mode 100644 Website/components/diagramview/panes/AddGroupPane.tsx delete mode 100644 Website/components/diagramview/panes/AttributeSelectionPanel.tsx delete mode 100644 Website/components/diagramview/panes/EntityActionsPane.tsx delete mode 100644 Website/components/diagramview/panes/LinkPropertiesPane.tsx delete mode 100644 Website/components/diagramview/panes/ResetToGroupPane.tsx delete mode 100644 Website/components/diagramview/panes/SquarePropertiesPane.tsx delete mode 100644 Website/components/diagramview/panes/TextPropertiesPane.tsx delete mode 100644 Website/components/diagramview/panes/index.ts delete mode 100644 Website/components/diagramview/renderers/DetailedDiagramRender.ts delete mode 100644 Website/components/diagramview/renderers/SimpleDiagramRender.ts delete mode 100644 Website/components/diagramview/shared/DiagramConstants.ts delete mode 100644 Website/hooks/useDiagram.ts diff --git a/Website/app/diagram/page.tsx b/Website/app/diagram/page.tsx index bbda9bd..d37e14b 100644 --- a/Website/app/diagram/page.tsx +++ b/Website/app/diagram/page.tsx @@ -9,7 +9,7 @@ export default function Home() { return ( - + diff --git a/Website/components/diagramview/DiagramCanvas.tsx b/Website/components/diagramview/DiagramCanvas.tsx deleted file mode 100644 index 8a77a2b..0000000 --- a/Website/components/diagramview/DiagramCanvas.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; -import React, { useRef, useEffect, useState } from 'react'; - -interface DiagramCanvasProps { - children?: React.ReactNode; -} - -export const DiagramCanvas: React.FC = ({ children }) => { - const canvasRef = useRef(null); - const [isCtrlPressed, setIsCtrlPressed] = useState(false); - - const { - isPanning, - initializePaper, - destroyPaper - } = useDiagramViewContext(); - - // Track Ctrl key state - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey || e.metaKey) { - setIsCtrlPressed(true); - } - }; - - const handleKeyUp = (e: KeyboardEvent) => { - if (!e.ctrlKey && !e.metaKey) { - setIsCtrlPressed(false); - } - }; - - window.addEventListener('keydown', handleKeyDown); - window.addEventListener('keyup', handleKeyUp); - - // Handle window blur to reset ctrl state - const handleBlur = () => setIsCtrlPressed(false); - window.addEventListener('blur', handleBlur); - - return () => { - window.removeEventListener('keydown', handleKeyDown); - window.removeEventListener('keyup', handleKeyUp); - window.removeEventListener('blur', handleBlur); - }; - }, []); - - useEffect(() => { - if (canvasRef.current) { - initializePaper(canvasRef.current, { - background: { - color: 'transparent' // Make paper background transparent to show CSS dots - } - }); - - return () => { - destroyPaper(); - }; - } - }, [initializePaper, destroyPaper]); - - // Determine cursor based on state - const getCursor = () => { - if (isPanning) return 'cursor-grabbing'; - if (isCtrlPressed) return 'cursor-grab'; - return 'cursor-crosshair'; // Default to crosshair for area selection - }; - - return ( -
-
- - {children} -
- ); -}; \ No newline at end of file diff --git a/Website/components/diagramview/DiagramControls.tsx b/Website/components/diagramview/DiagramControls.tsx deleted file mode 100644 index bb3eb26..0000000 --- a/Website/components/diagramview/DiagramControls.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from 'react'; -import { Button, Divider, Typography, Box } from '@mui/material'; -import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; -import { AspectRatioRounded, LayersRounded, RefreshRounded, SearchRounded, SettingsRounded, ZoomInRounded, ZoomOutRounded } from '@mui/icons-material'; - -export const DiagramControls: React.FC = () => { - const { - resetView, - fitToScreen - } = useDiagramViewContext(); - - return ( - - - - View Controls - - - - - - - - - - - - Tools - - - - - - - - - ); -}; - -export const DiagramZoomDisplay: React.FC = () => { - const { zoom } = useDiagramViewContext(); - - return ( - - Zoom: {Math.round(zoom * 100)}% - - ); -}; - -export const DiagramZoomControls: React.FC = () => { - const { zoomIn, zoomOut } = useDiagramViewContext(); - - return ( - - - - - ); -}; \ No newline at end of file diff --git a/Website/components/diagramview/DiagramRenderer.ts b/Website/components/diagramview/DiagramRenderer.ts deleted file mode 100644 index 785d072..0000000 --- a/Website/components/diagramview/DiagramRenderer.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { dia } from '@joint/core'; -import { AttributeType, EntityType } from '@/lib/Types'; -import { EntityElement } from '@/components/diagramview/elements/EntityElement'; - -export type IPortMap = Record; - -export abstract class DiagramRenderer { - protected graph: dia.Graph; - protected setSelectedKey?: (key: string | undefined) => void; - protected onLinkClickHandler?: (link: dia.Link) => void; - private instanceId: string; - protected currentSelectedKey?: string; - - constructor( - graph: dia.Graph | undefined | null, - options?: { - setSelectedKey?: (key: string | undefined) => void; - onLinkClick?: (link: dia.Link) => void; - }) { - this.instanceId = Math.random().toString(36).substr(2, 9); - if (!graph) throw new Error("Graph must be defined"); - this.graph = graph; - this.setSelectedKey = options?.setSelectedKey; - this.onLinkClickHandler = options?.onLinkClick; - - // Bind methods to preserve context - this.onLinkClick = this.onLinkClick.bind(this); - this.onDocumentClick = this.onDocumentClick.bind(this); - } - - abstract onDocumentClick(event: MouseEvent): void; - - abstract createEntity(entity: EntityType, position: { x: number, y: number }): { - element: dia.Element, - portMap: IPortMap - }; - - abstract createLinks(entity: EntityType, entityMap: Map, allEntities: EntityType[]): void; - - abstract highlightSelectedKey( - graph: dia.Graph, - entities: EntityType[], - selectedKey: string - ): void; - - abstract updateEntityAttributes(graph: dia.Graph, selectedKey: string | undefined): void; - - abstract onLinkClick(linkView: dia.LinkView, evt: dia.Event): void; - - abstract getVisibleAttributes(entity: EntityType): AttributeType[]; - - // Helper method to set selected key and track it internally - protected setAndTrackSelectedKey(key: string | undefined): void { - this.currentSelectedKey = key; - this.setSelectedKey?.(key); - } - - // Helper method to get current selected key - protected getCurrentSelectedKey(): string | undefined { - return this.currentSelectedKey; - } - - // Method to sync internal state when selectedKey is set externally - public updateSelectedKey(key: string | undefined): void { - this.currentSelectedKey = key; - } - - // Unified method to update an entity regardless of type - updateEntity(entitySchemaName: string, updatedEntity: EntityType): void { - // Find the entity element in the graph - const allElements = this.graph.getElements(); - - const entityElement = allElements.find(el => - (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') && - el.get('data')?.entity?.SchemaName === entitySchemaName - ); - - if (entityElement) { - // Update the element's data - entityElement.set('data', { entity: updatedEntity }); - - // Call the appropriate update method based on entity type - if (entityElement.get('type') === 'delegate.entity') { - // For detailed entities, use updateAttributes - const entityElementTyped = entityElement as unknown as { updateAttributes: (entity: EntityType) => void }; - if (entityElementTyped.updateAttributes) { - entityElementTyped.updateAttributes(updatedEntity); - } - } else if (entityElement.get('type') === 'delegate.simple-entity') { - // For simple entities, use updateEntity - const simpleEntityElementTyped = entityElement as unknown as { updateEntity: (entity: EntityType) => void }; - if (simpleEntityElementTyped.updateEntity) { - simpleEntityElementTyped.updateEntity(updatedEntity); - } - } - - // Recreate links for this entity to reflect attribute changes - this.recreateEntityLinks(updatedEntity); - } - } - - // Helper method to recreate links for a specific entity - private recreateEntityLinks(entity: EntityType): void { - // Remove existing links for this entity - const allElements = this.graph.getElements(); - const entityElement = allElements.find(el => - (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') && - el.get('data')?.entity?.SchemaName === entity.SchemaName - ); - - if (entityElement) { - // Remove all links connected to this entity - const connectedLinks = this.graph.getConnectedLinks(entityElement); - connectedLinks.forEach(link => link.remove()); - } - - // Recreate the entity map for link creation - const entityMap = new Map(); - - allElements.forEach(el => { - if (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') { - const entityData = el.get('data')?.entity; - if (entityData) { - // Create appropriate port map based on entity type - let portMap: IPortMap; - if (el.get('type') === 'delegate.entity') { - // For detailed entities, get the actual port map - const { portMap: detailedPortMap } = EntityElement.getVisibleItemsAndPorts(entityData); - portMap = detailedPortMap; - } else { - // For simple entities, use basic 4-directional ports - portMap = { - top: 'port-top', - right: 'port-right', - bottom: 'port-bottom', - left: 'port-left' - }; - } - - entityMap.set(entityData.SchemaName, { element: el, portMap }); - } - } - }); - - // Recreate links for all entities (this ensures all relationships are updated) - const allEntities: EntityType[] = []; - entityMap.forEach((entityInfo) => { - const entityData = entityInfo.element.get('data')?.entity; - if (entityData) { - allEntities.push(entityData); - } - }); - - entityMap.forEach((entityInfo) => { - const entityData = entityInfo.element.get('data')?.entity; - if (entityData) { - this.createLinks(entityData, entityMap, allEntities); - } - }); - } -} \ No newline at end of file diff --git a/Website/components/diagramview/DiagramResetButton.tsx b/Website/components/diagramview/DiagramResetButton.tsx deleted file mode 100644 index 12ab467..0000000 --- a/Website/components/diagramview/DiagramResetButton.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import { Button } from '@mui/material'; -import { RefreshRounded } from '@mui/icons-material'; - -interface DiagramResetButtonProps { - onReset: () => void; - disabled?: boolean; -} - -export const DiagramResetButton: React.FC = ({ - onReset, - disabled = false -}) => { - return ( - - ); -}; \ No newline at end of file diff --git a/Website/components/diagramview/DiagramView.tsx b/Website/components/diagramview/DiagramView.tsx index 867c6eb..4aa7f8e 100644 --- a/Website/components/diagramview/DiagramView.tsx +++ b/Website/components/diagramview/DiagramView.tsx @@ -1,27 +1,15 @@ 'use client'; -import React, { useEffect, useMemo, useState, useCallback, useRef } from 'react' -import { dia, util } from '@joint/core' -import { Groups } from "../../generated/Data" -import { SquareElement } from '@/components/diagramview/elements/SquareElement'; -import { TextElement } from '@/components/diagramview/elements/TextElement'; -import { DiagramCanvas } from '@/components/diagramview/DiagramCanvas'; -import { ZoomCoordinateIndicator } from '@/components/diagramview/ZoomCoordinateIndicator'; -import { EntityActionsPane, LinkPropertiesPane, LinkProperties } from '@/components/diagramview/panes'; -import { entityStyleManager } from '@/lib/entity-styling'; -import { SquarePropertiesPane } from '@/components/diagramview/panes/SquarePropertiesPane'; -import { TextPropertiesPane } from '@/components/diagramview/panes/TextPropertiesPane'; -import { calculateGridLayout, getDefaultLayoutOptions, calculateEntityHeight, estimateEntityDimensions } from '@/components/diagramview/GridLayoutManager'; -import { AttributeType } from '@/lib/Types'; -import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; -import { SidebarDiagramView } from './SidebarDiagramView'; -import { useSidebar } from '@/contexts/SidebarContext'; -import { SimpleDiagramRenderer } from './renderers/SimpleDiagramRender'; -import { DetailedDiagramRender } from './renderers/DetailedDiagramRender'; -import { Alert, Box } from '@mui/material'; -import { Science } from '@mui/icons-material'; +import { useSidebar } from "@/contexts/SidebarContext"; +import React, { useEffect } from "react"; +import { SidebarDiagramView } from "./SidebarDiagramView"; +import { Box } from "@mui/material"; -export default function DiagramView({ }: IDiagramView) { +interface IDiagramViewProps { + +} + +export default function DiagramView({ }: IDiagramViewProps) { const { setElement, expand } = useSidebar(); useEffect(() => { @@ -30,887 +18,8 @@ export default function DiagramView({ }: IDiagramView) { }, []) return ( - + + Hello + ); -} - -interface IDiagramView {} - -const DiagramContent = () => { - const { - graph, - paper, - selectedGroup, - currentEntities, - zoom, - mousePosition, - isPanning, - selectGroup, - fitToScreen, - addAttributeToEntity, - removeAttributeFromEntity, - diagramType, - removeEntityFromDiagram - } = useDiagramViewContext(); - - const [selectedKey, setSelectedKey] = useState(); - const [selectedEntityForActions, setSelectedEntityForActions] = useState(); - const [selectedArea, setSelectedArea] = useState<{ start: { x: number; y: number }; end: { x: number; y: number } }>({ start: { x: 0, y: 0 }, end: { x: 0, y: 0 } }); - const [isLoading, setIsLoading] = useState(true); - - // Persistent tracking of entity positions across renders - const entityPositionsRef = useRef>(new Map()); - - // Track previous diagram type to detect changes - const previousDiagramTypeRef = useRef(diagramType); - - // Wrapper for setSelectedKey to pass to renderer - const handleSetSelectedKey = useCallback((key: string | undefined) => { - setSelectedKey(key); - }, []); - - // Link click handler to pass to renderer - const handleLinkClick = useCallback((link: dia.Link) => { - setSelectedLink(link); - setIsLinkPropertiesSheetOpen(true); - }, []); - const [isEntityActionsSheetOpen, setIsEntityActionsSheetOpen] = useState(false); - const [selectedSquare, setSelectedSquare] = useState(null); - const [isSquarePropertiesSheetOpen, setIsSquarePropertiesSheetOpen] = useState(false); - const [selectedText, setSelectedText] = useState(null); - const [isTextPropertiesSheetOpen, setIsTextPropertiesSheetOpen] = useState(false); - const [selectedLink, setSelectedLink] = useState(null); - const [isLinkPropertiesSheetOpen, setIsLinkPropertiesSheetOpen] = useState(false); - const [isResizing, setIsResizing] = useState(false); - const [resizeData, setResizeData] = useState<{ - element: SquareElement; - handle: string; - startSize: { width: number; height: number }; - startPosition: { x: number; y: number }; - startPointer: { x: number; y: number }; - } | null>(null); - - const renderer = useMemo(() => { - if (!graph) return null; - - const RendererClass = (() => { - switch (diagramType) { - case 'simple': - return SimpleDiagramRenderer; - case 'detailed': - return DetailedDiagramRender; - default: - return SimpleDiagramRenderer; // fallback - } - })(); - - return new RendererClass(graph, { - setSelectedKey: handleSetSelectedKey, - onLinkClick: handleLinkClick - }); - }, [diagramType, graph, handleSetSelectedKey, handleLinkClick]); - - useEffect(() => { - if (Groups.length > 0 && !selectedGroup) { - selectGroup(Groups[0]); - } - }, [Groups, selectedGroup, selectGroup]); - - // Handle loading state when basic dependencies are ready - useEffect(() => { - if (graph && renderer) { // Remove paper dependency here since it might not be ready - // If we have the basic dependencies but no selected group or no entities, stop loading - if (!selectedGroup || currentEntities.length === 0) { - setIsLoading(false); - } - } - }, [graph, renderer, selectedGroup, currentEntities]); // Remove paper from dependencies - - useEffect(() => { - if (!renderer) return; - - // Bind the method to the renderer instance - const boundOnDocumentClick = renderer.onDocumentClick.bind(renderer); - document.addEventListener('click', boundOnDocumentClick); - return () => { - document.removeEventListener('click', boundOnDocumentClick); - }; - }, [renderer]); - - useEffect(() => { - if (!graph || !paper || !selectedGroup || !renderer) { - return; - } - - // Check if diagram type has changed and clear all positions if so - let diagramTypeChanged = false; - if (previousDiagramTypeRef.current !== diagramType) { - entityPositionsRef.current.clear(); - previousDiagramTypeRef.current = diagramType; - diagramTypeChanged = true; - } - - // Set loading state when starting diagram creation - setIsLoading(true); - - // If there are no entities, set loading to false immediately - if (currentEntities.length === 0) { - setIsLoading(false); - return; - } - - // Preserve squares, text elements, and existing entity positions before clearing - const squares = graph.getElements().filter(element => element.get('type') === 'delegate.square'); - const textElements = graph.getElements().filter(element => element.get('type') === 'delegate.text'); - const existingEntities = graph.getElements().filter(element => { - const entityData = element.get('data'); - return entityData?.entity; // This is an entity element - }); - - const squareData = squares.map(square => ({ - element: square, - data: square.get('data'), - position: square.position(), - size: square.size() - })); - const textData = textElements.map(textElement => ({ - element: textElement, - data: textElement.get('data'), - position: textElement.position(), - size: textElement.size() - })); - - // Update persistent position tracking with current positions - // Skip this if diagram type changed to ensure all entities are treated as new - if (!diagramTypeChanged) { - existingEntities.forEach(element => { - const entityData = element.get('data'); - if (entityData?.entity?.SchemaName) { - const position = element.position(); - entityPositionsRef.current.set(entityData.entity.SchemaName, position); - } - }); - } else { - } - - // Clean up position tracking for entities that are no longer in currentEntities - const currentEntityNames = new Set(currentEntities.map(e => e.SchemaName)); - for (const [schemaName] of entityPositionsRef.current) { - if (!currentEntityNames.has(schemaName)) { - entityPositionsRef.current.delete(schemaName); - } - } - - // Clear existing elements - graph.clear(); - - // Re-add preserved squares with their data - squareData.forEach(({ element, data, position, size }) => { - element.addTo(graph); - element.position(position.x, position.y); - element.resize(size.width, size.height); - element.set('data', data); - element.toBack(); // Keep squares at the back - }); - - // Re-add preserved text elements with their data - textData.forEach(({ element, data, position, size }) => { - element.addTo(graph); - element.position(position.x, position.y); - element.resize(size.width, size.height); - element.set('data', data); - element.toFront(); // Keep text elements at the front - }); - - // Calculate grid layout - const layoutOptions = getDefaultLayoutOptions(diagramType); - - // Get actual container dimensions - const containerRect = paper?.el?.getBoundingClientRect(); - const actualContainerWidth = containerRect?.width || layoutOptions.containerWidth; - const actualContainerHeight = containerRect?.height || layoutOptions.containerHeight; - - // Update layout options with actual container dimensions - const updatedLayoutOptions = { - ...layoutOptions, - containerWidth: actualContainerWidth, - containerHeight: actualContainerHeight, - diagramType: diagramType - }; - - // Separate new entities from existing ones using persistent position tracking - const newEntities = currentEntities.filter(entity => - !entityPositionsRef.current.has(entity.SchemaName) - ); - const existingEntitiesWithPositions = currentEntities.filter(entity => - entityPositionsRef.current.has(entity.SchemaName) - ); - - - // Store entity elements and port maps by SchemaName for easy lookup - const entityMap = new Map(); - const placedEntityPositions: { x: number; y: number; width: number; height: number }[] = []; - - // First, create existing entities with their preserved positions - existingEntitiesWithPositions.forEach((entity) => { - const position = entityPositionsRef.current.get(entity.SchemaName); - if (!position) return; // Skip if position is undefined - - const { element, portMap } = renderer.createEntity(entity, position); - entityMap.set(entity.SchemaName, { element, portMap }); - - // Track this position for collision avoidance - const dimensions = estimateEntityDimensions(entity, diagramType); - placedEntityPositions.push({ - x: position.x, - y: position.y, - width: dimensions.width, - height: dimensions.height - }); - }); - - - // Then, create new entities with grid layout that avoids already placed entities - if (newEntities.length > 0) { - // Calculate actual heights for new entities based on diagram type - const entityHeights = newEntities.map(entity => calculateEntityHeight(entity, diagramType)); - const maxEntityHeight = Math.max(...entityHeights, layoutOptions.entityHeight); - - const adjustedLayoutOptions = { - ...updatedLayoutOptions, - entityHeight: maxEntityHeight, - diagramType: diagramType - }; - - - const layout = calculateGridLayout(newEntities, adjustedLayoutOptions, placedEntityPositions); - - // Create new entities with grid layout positions - newEntities.forEach((entity, index) => { - const position = layout.positions[index] || { x: 50, y: 50 }; - const { element, portMap } = renderer.createEntity(entity, position); - entityMap.set(entity.SchemaName, { element, portMap }); - - // Update persistent position tracking for newly placed entities - entityPositionsRef.current.set(entity.SchemaName, position); - }); - } else { - } - - util.nextFrame(() => { - currentEntities.forEach(entity => { - renderer.createLinks(entity, entityMap, currentEntities); - }); - }); - - // Auto-fit to screen after a short delay to ensure all elements are rendered - setTimeout(() => { - fitToScreen(); - // Set loading to false once diagram is complete - setIsLoading(false); - }, 200); - }, [graph, paper, selectedGroup, currentEntities, diagramType]); - - useEffect(() => { - if (!graph || !renderer) return; - - // Sync the renderer's internal selectedKey state - renderer.updateSelectedKey(selectedKey); - - // Reset all links to default color first - graph.getLinks().forEach(link => { - link.attr('line/stroke', '#42a5f5'); - link.attr('line/strokeWidth', 2); - link.attr('line/targetMarker/stroke', '#42a5f5'); - link.attr('line/targetMarker/fill', '#42a5f5'); - link.attr('line/sourceMarker/stroke', '#42a5f5'); - }); - - // Only highlight if there's a selected key - if (selectedKey) { - renderer.highlightSelectedKey(graph, currentEntities, selectedKey); - } - }, [selectedKey, graph, currentEntities, renderer]); - - useEffect(() => { - if (!graph || !renderer) return; - renderer.updateEntityAttributes(graph, selectedKey); - }, [selectedKey, graph, renderer]); - - useEffect(() => { - if (!paper || !renderer) return; - - // Handle link clicks - paper.on('link:pointerclick', renderer.onLinkClick); - - // Handle entity clicks - const handleElementClick = (elementView: dia.ElementView, evt: dia.Event) => { - evt.stopPropagation(); - const element = elementView.model; - const elementType = element.get('type'); - - // Check if Ctrl is pressed - if so, skip opening any panes (selection is handled in useDiagram) - const isCtrlPressed = (evt.originalEvent as MouseEvent)?.ctrlKey || (evt.originalEvent as MouseEvent)?.metaKey; - if (isCtrlPressed) { - return; - } - - if (elementType === 'delegate.square') { - const squareElement = element as SquareElement; - - // Only open properties panel for squares (resize handles are shown on hover) - setSelectedSquare(squareElement); - setIsSquarePropertiesSheetOpen(true); - return; - } - - if (elementType === 'delegate.text') { - const textElement = element as TextElement; - - // Open properties panel for text elements - setSelectedText(textElement); - setIsTextPropertiesSheetOpen(true); - return; - } - - // Handle entity clicks - // Check if the click target is an attribute button - const target = evt.originalEvent?.target as HTMLElement; - const isAttributeButton = target?.closest('button[data-schema-name]'); - - // If clicking on an attribute, let the renderer handle it and don't open the entity actions sheet - if (isAttributeButton) { - return; - } - - const entityData = element.get('data'); - - if (entityData?.entity) { - setSelectedEntityForActions(entityData.entity.SchemaName); - setIsEntityActionsSheetOpen(true); - } - }; - - // Handle element hover for cursor indication - const handleElementMouseEnter = (elementView: dia.ElementView) => { - const element = elementView.model; - const elementType = element.get('type'); - - if (elementType === 'delegate.square') { - // Handle square hover - elementView.el.style.cursor = 'pointer'; - // Add a subtle glow effect for squares - element.attr('body/filter', 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.5))'); - - // Don't show resize handles on general hover - only on edge hover - return; - } - - if (elementType === 'delegate.text') { - // Handle text hover - elementView.el.style.cursor = 'pointer'; - // Add a subtle glow effect for text elements - element.attr('body/filter', 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.5))'); - return; - } - - // Handle entity hover using centralized style manager - const entityData = element.get('data'); - - if (entityData?.entity && paper) { - entityStyleManager.handleEntityMouseEnter(element, paper); - } - }; - - const handleElementMouseLeave = (elementView: dia.ElementView) => { - const element = elementView.model; - const elementType = element.get('type'); - - if (elementType === 'delegate.square') { - // Handle square hover leave - elementView.el.style.cursor = 'default'; - // Remove glow effect - element.attr('body/filter', 'none'); - - // Hide resize handles when leaving square area (unless selected for properties) - const squareElement = element as SquareElement; - if (selectedSquare?.id !== squareElement.id) { - squareElement.hideResizeHandles(); - } - return; - } - - if (elementType === 'delegate.text') { - // Handle text hover leave - elementView.el.style.cursor = 'default'; - // Remove glow effect - element.attr('body/filter', 'none'); - return; - } - - // Handle entity hover leave using centralized style manager - const entityData = element.get('data'); - - if (entityData?.entity && paper) { - entityStyleManager.handleEntityMouseLeave(element, paper); - } - }; - - paper.on('element:pointerclick', handleElementClick); - paper.on('element:mouseenter', handleElementMouseEnter); - paper.on('element:mouseleave', handleElementMouseLeave); - - // Handle mouse movement over squares to show resize handles only near edges - const handleSquareMouseMove = (cellView: dia.CellView, evt: dia.Event) => { - const element = cellView.model; - const elementType = element.get('type'); - - if (elementType === 'delegate.square') { - const squareElement = element as SquareElement; - const bbox = element.getBBox(); - - // Check if clientX and clientY are defined before using them - if (evt.clientX === undefined || evt.clientY === undefined) return; - - const paperLocalPoint = paper.clientToLocalPoint(evt.clientX, evt.clientY); - - const edgeThreshold = 15; // pixels from edge to show handles - const isNearEdge = ( - // Near left or right edge - (paperLocalPoint.x <= bbox.x + edgeThreshold || - paperLocalPoint.x >= bbox.x + bbox.width - edgeThreshold) || - // Near top or bottom edge - (paperLocalPoint.y <= bbox.y + edgeThreshold || - paperLocalPoint.y >= bbox.y + bbox.height - edgeThreshold) - ); - - if (isNearEdge) { - squareElement.showResizeHandles(); - cellView.el.style.cursor = 'move'; - } else { - // Only hide if not selected for properties (check current state) - const currentSelectedSquare = selectedSquare; - if (currentSelectedSquare?.id !== squareElement.id) { - squareElement.hideResizeHandles(); - } - cellView.el.style.cursor = 'move'; - } - } - }; - - paper.on('cell:mousemove', handleSquareMouseMove); - - // Handle pointer down for resize handles - capture before other events - paper.on('cell:pointerdown', (cellView: dia.CellView, evt: dia.Event) => { - const element = cellView.model; - const elementType = element.get('type'); - - if (elementType === 'delegate.square') { - const target = evt.target as HTMLElement; - - // More reliable selector detection for resize handles - let selector = target.getAttribute('joint-selector'); - - if (!selector) { - // Try to find parent with selector - let parent = target.parentElement; - let depth = 0; - while (parent && !selector && depth < 5) { - selector = parent.getAttribute('joint-selector'); - parent = parent.parentElement; - depth++; - } - } - - if (selector && selector.startsWith('resize-')) { - evt.stopPropagation(); - evt.preventDefault(); - - const squareElement = element as SquareElement; - const bbox = element.getBBox(); - - const resizeInfo = { - element: squareElement, - handle: selector, - startSize: { width: bbox.width, height: bbox.height }, - startPosition: { x: bbox.x, y: bbox.y }, - startPointer: { x: evt.clientX || 0, y: evt.clientY || 0 } - }; - - setResizeData(resizeInfo); - setIsResizing(true); - } - } - }); - - return () => { - paper.off('link:pointerclick', renderer.onLinkClick); - paper.off('element:pointerclick', handleElementClick); - paper.off('element:mouseenter', handleElementMouseEnter); - paper.off('element:mouseleave', handleElementMouseLeave); - paper.off('cell:mousemove', handleSquareMouseMove); - paper.off('cell:pointerdown'); - }; - }, [paper, renderer, selectedSquare]); - - // Handle resize operations - useEffect(() => { - if (!isResizing || !resizeData || !paper) return; - - let animationId: number; - - const handleMouseMove = (evt: MouseEvent) => { - if (!resizeData) return; - - // Cancel previous animation frame to prevent stacking - if (animationId) { - cancelAnimationFrame(animationId); - } - - // Use requestAnimationFrame for smooth updates - animationId = requestAnimationFrame(() => { - const { element, handle, startSize, startPosition, startPointer } = resizeData; - const deltaX = evt.clientX - startPointer.x; - const deltaY = evt.clientY - startPointer.y; - - // Adjust deltas based on paper scaling and translation - const scale = paper.scale(); - const adjustedDeltaX = deltaX / scale.sx; - const adjustedDeltaY = deltaY / scale.sy; - - const newSize = { width: startSize.width, height: startSize.height }; - const newPosition = { x: startPosition.x, y: startPosition.y }; - - // Calculate new size and position based on resize handle - switch (handle) { - case 'resize-se': // Southeast - newSize.width = Math.max(50, startSize.width + adjustedDeltaX); - newSize.height = Math.max(30, startSize.height + adjustedDeltaY); - break; - case 'resize-sw': // Southwest - newSize.width = Math.max(50, startSize.width - adjustedDeltaX); - newSize.height = Math.max(30, startSize.height + adjustedDeltaY); - newPosition.x = startPosition.x + adjustedDeltaX; - break; - case 'resize-ne': // Northeast - newSize.width = Math.max(50, startSize.width + adjustedDeltaX); - newSize.height = Math.max(30, startSize.height - adjustedDeltaY); - newPosition.y = startPosition.y + adjustedDeltaY; - break; - case 'resize-nw': // Northwest - newSize.width = Math.max(50, startSize.width - adjustedDeltaX); - newSize.height = Math.max(30, startSize.height - adjustedDeltaY); - newPosition.x = startPosition.x + adjustedDeltaX; - newPosition.y = startPosition.y + adjustedDeltaY; - break; - case 'resize-e': // East - newSize.width = Math.max(50, startSize.width + adjustedDeltaX); - break; - case 'resize-w': // West - newSize.width = Math.max(50, startSize.width - adjustedDeltaX); - newPosition.x = startPosition.x + adjustedDeltaX; - break; - case 'resize-s': // South - newSize.height = Math.max(30, startSize.height + adjustedDeltaY); - break; - case 'resize-n': // North - newSize.height = Math.max(30, startSize.height - adjustedDeltaY); - newPosition.y = startPosition.y + adjustedDeltaY; - break; - } - - // Apply the new size and position in a single batch update - element.resize(newSize.width, newSize.height); - element.position(newPosition.x, newPosition.y); - }); - }; - - const handleMouseUp = () => { - if (animationId) { - cancelAnimationFrame(animationId); - } - setIsResizing(false); - setResizeData(null); - }; - - // Add global event listeners - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - // Cleanup - return () => { - if (animationId) { - cancelAnimationFrame(animationId); - } - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isResizing, resizeData, paper]); - - // Handle clicking outside to deselect squares and manage area selection - useEffect(() => { - if (!paper) return; - - const handleBlankClick = () => { - if (selectedSquare) { - selectedSquare.hideResizeHandles(); - setSelectedSquare(null); - setIsSquarePropertiesSheetOpen(false); - } - } - - const handleBlankPointerDown = (_: dia.Event, x: number, y: number) => { - - // Don't set selected area if we were panning - if (!isPanning) { - setSelectedArea({ - ...selectedArea, - start: { x, y } - }); - } - }; - - const handleBlankPointerUp = (evt: dia.Event, x: number, y: number) => { - if (!isPanning && Math.abs(selectedArea.start.x - x) > 10 && Math.abs(selectedArea.start.y - y) > 10) { - // TODO - } - }; - - paper.on('blank:pointerdown', handleBlankPointerDown); - paper.on('blank:pointerup', handleBlankPointerUp); - paper.on('blank:pointerclick', handleBlankClick); - - return () => { - paper.off('blank:pointerdown', handleBlankPointerDown); - paper.off('blank:pointerup', handleBlankPointerUp); - paper.off('blank:pointerclick', handleBlankClick); - }; - }, [paper, selectedSquare, isPanning, selectedArea]); - - const handleAddAttribute = (attribute: AttributeType) => { - if (!selectedEntityForActions || !renderer) return; - addAttributeToEntity(selectedEntityForActions, attribute, renderer); - }; - - const handleRemoveAttribute = (attribute: AttributeType) => { - if (!selectedEntityForActions || !renderer) return; - removeAttributeFromEntity(selectedEntityForActions, attribute, renderer); - }; - - const handleDeleteEntity = () => { - if (selectedEntityForActions) { - removeEntityFromDiagram(selectedEntityForActions); - setIsEntityActionsSheetOpen(false); - setSelectedEntityForActions(undefined); - } - }; - - const handleDeleteSquare = () => { - if (selectedSquare && graph) { - // Remove the square from the graph - selectedSquare.remove(); - // Clear the selection - setSelectedSquare(null); - setIsSquarePropertiesSheetOpen(false); - } - }; - - const handleDeleteText = () => { - if (selectedText && graph) { - // Remove the text from the graph - selectedText.remove(); - // Clear the selection - setSelectedText(null); - setIsTextPropertiesSheetOpen(false); - } - }; - - const handleUpdateLink = (linkId: string | number, properties: LinkProperties) => { - if (!graph) return; - - const link = graph.getCell(linkId) as dia.Link; - if (!link) return; - - // Update link appearance - link.attr('line/stroke', properties.color); - link.attr('line/strokeWidth', properties.strokeWidth); - link.attr('line/targetMarker/stroke', properties.color); - link.attr('line/targetMarker/fill', properties.color); - link.attr('line/sourceMarker/stroke', properties.color); - - if (properties.strokeDasharray) { - link.attr('line/strokeDasharray', properties.strokeDasharray); - } else { - link.removeAttr('line/strokeDasharray'); - } - - // Update or remove label - if (properties.label) { - link.label(0, { - attrs: { - rect: { - fill: 'white', - stroke: '#e5e7eb', - strokeWidth: 1, - rx: 4, - ry: 4, - ref: 'text', - refX: -8, - refY: 0, - refWidth: '100%', - refHeight: '100%', - refWidth2: 16, - refHeight2: 8, - }, - text: { - text: properties.label, - fill: properties.color, - fontSize: 14, - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - textAnchor: 'start', - dominantBaseline: 'central', - } - }, - position: { - distance: 0.5, - offset: -1 - } - }); - } else { - link.removeLabel(0); - } - }; - - // Find the selected entity for actions - const selectedEntityForActionsData = currentEntities.find(entity => entity.SchemaName === selectedEntityForActions); - - // Find the entity display name for the modal - const selectedEntity = currentEntities.find(entity => entity.SchemaName === selectedEntityForActions); - - // Get available and visible attributes for the selected entity - const availableAttributes = selectedEntity?.Attributes || []; - const visibleAttributes = selectedEntity && renderer - ? renderer.getVisibleAttributes(selectedEntity) - : []; - - return ( - <> -
- {/* Beta Disclaimer Banner */} - - } - sx={{ - borderRadius: 0, - '& .MuiAlert-message': { - display: 'flex', - alignItems: 'center', - gap: 1, - flexWrap: 'wrap' - } - }} - > - - - Open Beta Feature: - {' '}This ER Diagram feature is currently in beta. Some functionality may not work fully. - {' '}We do not recommend more than 20 entities - - - - - - {/* Diagram Area */} - - {isLoading && ( - theme.palette.mode === 'dark' - ? 'rgba(17, 24, 39, 0.5)' - : 'rgba(248, 250, 252, 0.5)' - }} - > - - - {[...Array(3)].map((_, i) => ( - - ))} - - - Loading diagram... - - - - )} - - {/* Zoom and Coordinate Indicator */} - - - -
- - {/* Entity Actions Pane */} - - - {/* Square Properties Pane */} - - - {/* Text Properties Pane */} - - - {/* Link Properties Pane */} - { - setIsLinkPropertiesSheetOpen(open); - if (!open) setSelectedLink(null); - }} - selectedLink={selectedLink} - onUpdateLink={handleUpdateLink} - /> - - ) -}; +} \ No newline at end of file diff --git a/Website/components/diagramview/GridLayoutManager.ts b/Website/components/diagramview/GridLayoutManager.ts index 9b32c07..90d70b8 100644 --- a/Website/components/diagramview/GridLayoutManager.ts +++ b/Website/components/diagramview/GridLayoutManager.ts @@ -1,7 +1,4 @@ import { EntityType } from '@/lib/Types'; -import { EntityElement } from '@/components/diagramview/elements/EntityElement'; - -export type DiagramType = 'simple' | 'detailed'; export interface GridLayoutOptions { containerWidth: number; @@ -10,7 +7,6 @@ export interface GridLayoutOptions { entityHeight: number; padding: number; margin: number; - diagramType?: DiagramType; } export interface GridPosition { @@ -29,23 +25,7 @@ export interface GridLayoutResult { /** * Calculates the actual height of an entity based on its visible attributes and diagram type */ -export const calculateEntityHeight = (entity: EntityType, diagramType: DiagramType = 'detailed'): number => { - // For simple diagrams, use fixed small dimensions - if (diagramType === 'simple') { - return 80; // Fixed height for simple entities - } - - // For detailed diagrams, calculate based on content - const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); - const itemHeight = 28; - const itemYSpacing = 8; - const addButtonHeight = 32; // Space for add button - const headerHeight = 80; - const startY = headerHeight + itemYSpacing * 2; - - // Calculate height including the add button - return startY + visibleItems.length * (itemHeight + itemYSpacing) + addButtonHeight + itemYSpacing; -}; +export const calculateEntityHeight = (): number => { return 80; }; /** * Calculates optimal grid layout for entities based on screen aspect ratio @@ -78,7 +58,7 @@ export const calculateGridLayout = ( const maxY = Math.max(...existingPositions.map(pos => pos.y + pos.height)); // Get sample entity dimensions for spacing calculations - const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }, options.diagramType); + const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }); // Start new entities to the right of existing ones, or on the next row startColumn = Math.floor((maxX + padding - margin) / (sampleDimensions.width + padding)); @@ -90,7 +70,7 @@ export const calculateGridLayout = ( } // Determine how many columns can fit based on actual entity dimensions - const sampleEntityDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }, options.diagramType); + const sampleEntityDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }); const actualEntityWidth = sampleEntityDimensions.width; const maxColumns = Math.max(1, Math.floor((containerWidth - margin * 2 + padding) / (actualEntityWidth + padding))); @@ -101,7 +81,7 @@ export const calculateGridLayout = ( for (let i = 0; i < entities.length; i++) { const entity = entities[i]; - const entityDimensions = estimateEntityDimensions(entity, options.diagramType); + const entityDimensions = estimateEntityDimensions(entity); const height = entityDimensions.height; const width = entityDimensions.width; @@ -154,7 +134,7 @@ export const calculateGridLayout = ( } } - const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }, options.diagramType); + const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }); const gridWidth = Math.min(entities.length, maxColumns) * sampleDimensions.width + (Math.min(entities.length, maxColumns) - 1) * padding; const gridHeight = (currentRow + 1) * (sampleDimensions.height + padding) - padding; @@ -171,30 +151,17 @@ export const calculateGridLayout = ( /** * Estimates entity dimensions based on content and diagram type */ -export const estimateEntityDimensions = (entity: EntityType, diagramType: DiagramType = 'detailed'): { width: number; height: number } => { - if (diagramType === 'simple') { - // Fixed dimensions for simple entities +export const estimateEntityDimensions = (entity: EntityType): { width: number; height: number } => { return { width: 200, height: 80 }; - } - - // Base dimensions for detailed entities - const baseWidth = 480; // Match the entity width used in EntityElement - const height = calculateEntityHeight(entity, diagramType); // Use actual calculated height - - return { - width: baseWidth, - height: height - }; }; /** * Gets default layout options based on diagram type */ -export const getDefaultLayoutOptions = (diagramType: DiagramType = 'detailed'): GridLayoutOptions => { - if (diagramType === 'simple') { +export const getDefaultLayoutOptions = (): GridLayoutOptions => { return { containerWidth: 1920, containerHeight: 1080, @@ -202,17 +169,5 @@ export const getDefaultLayoutOptions = (diagramType: DiagramType = 'detailed'): entityHeight: 80, // Smaller height for simple entities padding: 40, // Less padding for simple diagrams margin: 40, // Less margin for simple diagrams - diagramType: 'simple' }; - } - - return { - containerWidth: 1920, // Use a wider default container - containerHeight: 1080, // Use a taller default container - entityWidth: 480, - entityHeight: 400, // This will be overridden by actual calculation - padding: 80, // Reduced padding for better space utilization - margin: 80, - diagramType: 'detailed' - }; }; \ No newline at end of file diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index 2ddd67e..a8ffdcf 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -1,369 +1,14 @@ -import React, { useState } from 'react'; -import { - Tabs, - Tab, - Box, - Button, - Collapse, - Typography, - Divider, -} from '@mui/material'; -import { CheckBoxOutlineBlankRounded, ChevronRightRounded, DeleteRounded, DriveFolderUploadRounded, ExpandMoreRounded, FolderRounded, HardwareRounded, PeopleRounded, RttRounded, SaveRounded, SettingsRounded, SmartphoneRounded, SyncRounded } from '@mui/icons-material'; -import { useDiagramViewContextSafe } from '@/contexts/DiagramViewContext'; -import { AddEntityPane, AddGroupPane, ResetToGroupPane } from '@/components/diagramview/panes'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { GroupType } from '@/lib/Types'; -import CustomTabPanel from '../shared/elements/TabPanel'; +import { Box } from '@mui/material'; +import React from 'react'; interface ISidebarDiagramViewProps { } export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { - const diagramContext = useDiagramViewContextSafe(); - const isMobile = useIsMobile(); - const [isDataExpanded, setIsDataExpanded] = useState(true); - const [isGeneralExpanded, setIsGeneralExpanded] = useState(false); - const [isEntitySheetOpen, setIsEntitySheetOpen] = useState(false); - const [isGroupSheetOpen, setIsGroupSheetOpen] = useState(false); - const [isResetSheetOpen, setIsResetSheetOpen] = useState(false); - const [tab, setTab] = useState(0); - - // If not in diagram context, show a message or return null - if (!diagramContext) { - return ( -
-
- -

Diagram tools are only available on the diagram page.

-
-
- ); - } - - const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType, clearDiagram } = diagramContext; - - const handleLoadDiagram = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - loadDiagram(file).catch(error => { - alert('Failed to load diagram: ' + error.message); - }); - } - // Reset input value to allow loading the same file again - event.target.value = ''; - }; - - const handleResetToGroup = (group: GroupType) => { - // First clear the entire diagram - clearDiagram(); - // Then add the selected group - addGroupToDiagram(group); - }; - - // Use the clearDiagram function from the hook - // const clearDiagram function is already available from the context - return ( -
- - setTab(newValue)} aria-label="Diagram view tabs" variant="fullWidth"> - - - Build - - } - sx={{ minWidth: 0, flex: 1, fontSize: '0.75rem' }} - /> - - - Settings - - } - sx={{ minWidth: 0, flex: 1, fontSize: '0.75rem' }} - /> - - - - {/* Mobile Notice */} - {isMobile && ( - - - - - Mobile Mode - - - Some advanced features may have limited functionality on mobile devices. - For the best experience, use a desktop computer. - - - - )} - - {/* Data Section */} - - - - - - - - - - - {/* General Section */} - - - - - - - - - - - - - - - Diagram Type - - - Choose between simple or detailed entity view - - - - - - - - - - - - Save & Load - - - Save your diagram or load an existing one - - - - - - - - - - - - Current Settings - - - - Diagram Type: {diagramType} - - - Entities in Diagram: {currentEntities.length} - - - - - - - - - Diagram Actions - - - Reset or clear your diagram - - - - - - - - - - {/* Add Entity Pane */} - - - {/* Add Group Pane */} - - - {/* Reset to Group Pane */} - -
+ + Hello + ); } \ No newline at end of file diff --git a/Website/components/diagramview/ZoomCoordinateIndicator.tsx b/Website/components/diagramview/ZoomCoordinateIndicator.tsx deleted file mode 100644 index c0dbab5..0000000 --- a/Website/components/diagramview/ZoomCoordinateIndicator.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { MouseRounded, ZoomInRounded } from '@mui/icons-material'; -import React from 'react'; - -interface ZoomCoordinateIndicatorProps { - zoom: number; - mousePosition: { x: number; y: number } | null; -} - -export const ZoomCoordinateIndicator: React.FC = ({ - zoom, - mousePosition -}) => { - const zoomPercentage = Math.round(zoom * 100); - - return ( -
-
-
- - - {zoomPercentage}% - -
- - {mousePosition && ( - <> -
-
- - - X: {mousePosition.x}, Y: {mousePosition.y} - -
- - )} -
-
- ); -}; \ No newline at end of file diff --git a/Website/components/diagramview/elements/EntityAttribute.ts b/Website/components/diagramview/elements/EntityAttribute.ts deleted file mode 100644 index ae8bdae..0000000 --- a/Website/components/diagramview/elements/EntityAttribute.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { AttributeType } from "@/lib/Types"; - -interface IEntityAttribute { - attribute: AttributeType; - isKey: boolean; - isSelected?: boolean; -} - -export const EntityAttribute = ({ attribute, isKey, isSelected = false }: IEntityAttribute): string => { - let icon = ''; - if (isKey) { - icon = `` - } else if (attribute.AttributeType === 'LookupAttribute') { - icon = `` - } else if (attribute.AttributeType === 'StringAttribute') { - icon = `` - } else if (attribute.AttributeType === 'IntegerAttribute' || attribute.AttributeType === 'DecimalAttribute') { - icon = `` - } else if (attribute.AttributeType === 'DateTimeAttribute') { - icon = `` - } else if (attribute.AttributeType === 'BooleanAttribute') { - icon = `` - } else if (attribute.AttributeType === 'ChoiceAttribute') { - icon = `` - } - - const isClickable = isKey || attribute.AttributeType === 'LookupAttribute'; - const buttonClasses = `w-full rounded-sm my-[4px] p-[4px] flex items-center h-[28px] ${isClickable ? 'transition-colors duration-300 hover:bg-blue-200 cursor-pointer' : ''}`; - const bgClass = isSelected ? 'bg-red-200 border-2 border-red-400' : 'bg-gray-100'; - - const titleText = isKey - ? 'Click to highlight incoming relationships' - : attribute.AttributeType === 'LookupAttribute' - ? 'Click to highlight outgoing relationships' - : ''; - - return ` - - `; -}; \ No newline at end of file diff --git a/Website/components/diagramview/elements/EntityBody.ts b/Website/components/diagramview/elements/EntityBody.ts deleted file mode 100644 index f283292..0000000 --- a/Website/components/diagramview/elements/EntityBody.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { AttributeType, EntityType } from '@/lib/Types' -import { EntityAttribute } from './EntityAttribute'; - -interface IEntityBody { - entity: EntityType; - visibleItems: AttributeType[]; - selectedKey?: string; -} - -export function EntityBody({ entity, visibleItems, selectedKey }: IEntityBody): string { - - const icon = entity.IconBase64 != null - ? `data:image/svg+xml;base64,${entity.IconBase64}` - : '/vercel.svg'; - - return ` -
- - -
-
- -
-
-

${entity.DisplayName}

-

${entity.SchemaName}

-
-
- -
- ${visibleItems.map((attribute, i) => (EntityAttribute({ - attribute, - isKey: i == 0, - isSelected: selectedKey === attribute.SchemaName - }))).join('')} -
-
- `; -} diff --git a/Website/components/diagramview/elements/EntityElement.ts b/Website/components/diagramview/elements/EntityElement.ts deleted file mode 100644 index 7fefef9..0000000 --- a/Website/components/diagramview/elements/EntityElement.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { AttributeType, EntityType } from '@/lib/Types'; -import { dia } from '@joint/core'; -import { EntityBody } from './EntityBody'; - -interface IEntityElement { - entity: EntityType; -} - -export class EntityElement extends dia.Element { - - initialize(...args: Parameters) { - super.initialize(...args); - const { entity } = this.get('data') as IEntityElement; - if (entity) this.updateAttributes(entity); - } - - static getVisibleItemsAndPorts(entity: EntityType) { - // Get the visible attributes list - if not set, use default logic - const visibleAttributeSchemaNames = (entity as EntityType & { visibleAttributeSchemaNames?: string[] }).visibleAttributeSchemaNames; - - if (visibleAttributeSchemaNames) { - // Use the explicit visible attributes list - const visibleItems = entity.Attributes.filter(attr => - visibleAttributeSchemaNames.includes(attr.SchemaName) - ); - - // Always ensure primary key is first if it exists - const primaryKeyAttribute = entity.Attributes.find(attr => attr.IsPrimaryId); - if (primaryKeyAttribute && !visibleItems.some(attr => attr.IsPrimaryId)) { - visibleItems.unshift(primaryKeyAttribute); - } else if (primaryKeyAttribute) { - // Move primary key to front if it exists - const filteredItems = visibleItems.filter(attr => !attr.IsPrimaryId); - visibleItems.splice(0, visibleItems.length, primaryKeyAttribute, ...filteredItems); - } - - // Map SchemaName to port name - const portMap: Record = {}; - for (const attr of visibleItems) { - portMap[attr.SchemaName.toLowerCase()] = `port-${attr.SchemaName.toLowerCase()}`; - } - return { visibleItems, portMap }; - } - - // Fallback to default logic for entities without explicit visible list - // Get the primary key attribute - const primaryKeyAttribute = entity.Attributes.find(attr => attr.IsPrimaryId) ?? - { DisplayName: "Key", SchemaName: entity.SchemaName + "id" } as AttributeType; - - // Get custom lookup attributes (initially visible) - const customLookupAttributes = entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" && - attr.IsCustomAttribute - ); - - // Combine primary key and custom lookup attributes - const visibleItems = [ - primaryKeyAttribute, - ...customLookupAttributes - ]; - - // Map SchemaName to port name - const portMap: Record = {}; - for (const attr of visibleItems) { - portMap[attr.SchemaName.toLowerCase()] = `port-${attr.SchemaName.toLowerCase()}`; - } - return { visibleItems, portMap }; - } - - updateAttributes(entity: EntityType) { - const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); - const selectedKey = (this.get('data') as IEntityElement & { selectedKey?: string })?.selectedKey; - const html = EntityBody({ entity, visibleItems, selectedKey }); - - // Markup - const baseMarkup = [ - { tagName: 'rect', selector: 'body' }, - { tagName: 'foreignObject', selector: 'fo' } - ]; - - this.set('markup', baseMarkup); - - const itemHeight = 28; - const itemYSpacing = 8; - const headerHeight = 80; - const startY = headerHeight + itemYSpacing * 2; - - // Calculate height dynamically based on number of visible items - const height = startY + visibleItems.length * (itemHeight + itemYSpacing) + 2; - - const leftPorts: dia.Element.Port[] = []; - const rightPorts: dia.Element.Port[] = []; - - visibleItems.forEach((attr, i) => { - const portId = `port-${attr.SchemaName.toLowerCase()}`; - const yPosition = startY + i * (itemHeight + itemYSpacing); - - const portConfig = { - id: portId, - group: attr.AttributeType === "LookupAttribute" ? 'right' : 'left', - args: { y: yPosition }, - attrs: { - circle: { - r: 6, - magnet: true, - stroke: '#31d0c6', - fill: '#fff', - strokeWidth: 2 - } - } - }; - - // Only LookupAttributes get ports (for relationships) - // Other attributes are just displayed in the entity - if (attr.AttributeType === "LookupAttribute") { - portConfig.group = 'right'; - rightPorts.push(portConfig); - } else if (i === 0) { // Key attribute gets a left port - portConfig.group = 'left'; - leftPorts.push(portConfig); - } - // Other attributes don't get ports - they're just displayed - }); - - this.set('ports', { - groups: { - left: { - position: { - name: 'left', - }, - attrs: { - circle: { - r: 6, - magnet: true, - stroke: '#31d0c6', - fill: '#fff', - strokeWidth: 2 - } - } - }, - right: { - position: { - name: 'right', - }, - attrs: { - circle: { - r: 6, - magnet: true, - stroke: '#31d0c6', - fill: '#fff', - strokeWidth: 2 - } - } - } - }, - items: [...leftPorts, ...rightPorts] - }); - - this.set('attrs', { - ...this.get('attrs'), - fo: { - refWidth: '100%', - refHeight: '100%', - html - } - }); - - this.resize(480, height); - } - - defaults() { - return { - type: 'delegate.entity', - size: { width: 480, height: 360 }, - attrs: { - body: { - refWidth: '100%', - refHeight: '100%', - fill: '#fff', - stroke: '#d1d5db', - rx: 12 - }, - fo: { - refX: 0, - refY: 0 - } - }, - markup: [] // dynamic in updateItems - }; - } -} diff --git a/Website/components/diagramview/elements/SimpleEntityElement.ts b/Website/components/diagramview/elements/SimpleEntityElement.ts deleted file mode 100644 index 2c79936..0000000 --- a/Website/components/diagramview/elements/SimpleEntityElement.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { EntityType } from '@/lib/Types'; -import { dia } from '@joint/core'; - -interface ISimpleEntityElement { - entity: EntityType; -} - -export class SimpleEntityElement extends dia.Element { - - initialize(...args: Parameters) { - super.initialize(...args); - const { entity } = this.get('data') as ISimpleEntityElement; - if (entity) this.updateEntity(entity); - - // Add 4 ports: top, left, right, bottom, and make them invisible - this.set('ports', { - groups: { - top: { - position: { name: 'top' }, - attrs: { - circle: { - r: 6, - magnet: true, - fill: 'var(--mui-palette-background-paper)', - stroke: '#42a5f5', - strokeWidth: 2, - visibility: 'hidden', - }, - }, - }, - left: { - position: { name: 'left', args: { dx: 6 } }, - attrs: { - circle: { - r: 6, - magnet: true, - fill: 'var(--mui-palette-background-paper)', - stroke: '#42a5f5', - strokeWidth: 2, - visibility: 'hidden', - }, - }, - }, - right: { - position: { name: 'right' }, - attrs: { - circle: { - r: 6, - magnet: true, - fill: 'var(--mui-palette-background-paper)', - stroke: '#42a5f5', - strokeWidth: 2, - visibility: 'hidden', - }, - }, - }, - bottom: { - position: { name: 'bottom' }, - attrs: { - circle: { - r: 6, - magnet: true, - fill: 'var(--mui-palette-background-paper)', - stroke: '#42a5f5', - strokeWidth: 2, - visibility: 'hidden', - }, - }, - }, - }, - items: [ - { id: 'port-top', group: 'top' }, - { id: 'port-left', group: 'left' }, - { id: 'port-right', group: 'right' }, - { id: 'port-bottom', group: 'bottom' }, - ], - }); - } - - updateEntity(entity: EntityType) { - const html = this.createSimpleEntityHTML(entity); - - // Markup - const baseMarkup = [ - { tagName: 'rect', selector: 'body' }, - { tagName: 'foreignObject', selector: 'fo' } - ]; - - this.set('markup', baseMarkup); - - // Simple entity with just name - fixed size - const width = 200; - const height = 80; - - this.set('attrs', { - ...this.get('attrs'), - body: { - refWidth: '100%', - refHeight: '100%', - fill: 'var(--mui-palette-background-paper)', - stroke: '#d1d5db', - rx: 12 - }, - fo: { - refWidth: '100%', - refHeight: '100%', - html - } - }); - - this.resize(width, height); - } - - private createSimpleEntityHTML(entity: EntityType): string { - return ` -
-
-
-

${entity.DisplayName}

-

${entity.SchemaName}

-
-
-
- `; - } - - defaults() { - return { - type: 'delegate.entity', - size: { width: 200, height: 80 }, - attrs: { - body: { - refWidth: '100%', - refHeight: '100%', - fill: '#fff', - stroke: '#d1d5db', - rx: 12 - }, - fo: { - refX: 0, - refY: 0 - } - }, - markup: [] // dynamic in updateEntity - }; - } -} \ No newline at end of file diff --git a/Website/components/diagramview/elements/SquareElement.ts b/Website/components/diagramview/elements/SquareElement.ts deleted file mode 100644 index e17f391..0000000 --- a/Website/components/diagramview/elements/SquareElement.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { dia } from '@joint/core'; -import { PRESET_COLORS } from '../shared/DiagramConstants'; - -export interface SquareElementData { - id?: string; - borderColor?: string; - fillColor?: string; - borderWidth?: number; - borderType?: 'solid' | 'dashed' | 'dotted'; - opacity?: number; - isSelected?: boolean; -} - -export class SquareElement extends dia.Element { - - initialize(...args: Parameters) { - super.initialize(...args); - this.updateSquareAttrs(); - } - - updateSquareAttrs() { - const data = this.get('data') as SquareElementData || {}; - const { - borderColor = PRESET_COLORS.borders[0].value, - fillColor = PRESET_COLORS.fills[0].value, - borderWidth = 2, - borderType = 'dashed', - opacity = 0.7 - } = data; - - this.attr({ - body: { - fill: fillColor, - fillOpacity: opacity, - stroke: borderColor, - strokeWidth: borderWidth, - strokeDasharray: this.getStrokeDashArray(borderType), - rx: 8, // Rounded corners - ry: 8 - } - }); - } - - private getStrokeDashArray(borderType: string): string { - switch (borderType) { - case 'dashed': - return '10,5'; - case 'dotted': - return '2,3'; - default: - return 'none'; - } - } - - defaults() { - return { - type: 'delegate.square', - size: { width: 150, height: 100 }, - attrs: { - body: { - refWidth: '100%', - refHeight: '100%', - fill: '#f1f5f9', - fillOpacity: 0.7, - stroke: '#64748b', - strokeWidth: 2, - rx: 8, - ry: 8, - cursor: 'pointer' - }, - // Resize handles - initially hidden - 'resize-nw': { - ref: 'body', - refX: 0, - refY: 0, - x: -4, - y: -4, - width: 8, - height: 8, - fill: '#3b82f6', - stroke: '#1e40af', - strokeWidth: 1, - cursor: 'nw-resize', - visibility: 'hidden', - pointerEvents: 'all' - }, - 'resize-ne': { - ref: 'body', - refX: '100%', - refY: 0, - x: -4, - y: -4, - width: 8, - height: 8, - fill: '#3b82f6', - stroke: '#1e40af', - strokeWidth: 1, - cursor: 'ne-resize', - visibility: 'hidden', - pointerEvents: 'all' - }, - 'resize-sw': { - ref: 'body', - refX: 0, - refY: '100%', - x: -4, - y: -4, - width: 8, - height: 8, - fill: '#3b82f6', - stroke: '#1e40af', - strokeWidth: 1, - cursor: 'sw-resize', - visibility: 'hidden', - pointerEvents: 'all' - }, - 'resize-se': { - ref: 'body', - refX: '100%', - refY: '100%', - x: -4, - y: -4, - width: 8, - height: 8, - fill: '#3b82f6', - stroke: '#1e40af', - strokeWidth: 1, - cursor: 'se-resize', - visibility: 'hidden', - pointerEvents: 'all' - }, - // Side handles - 'resize-n': { - ref: 'body', - refX: '50%', - refY: 0, - x: -4, - y: -4, - width: 8, - height: 8, - fill: '#3b82f6', - stroke: '#1e40af', - strokeWidth: 1, - cursor: 'n-resize', - visibility: 'hidden', - pointerEvents: 'all' - }, - 'resize-s': { - ref: 'body', - refX: '50%', - refY: '100%', - x: -4, - y: -4, - width: 8, - height: 8, - fill: '#3b82f6', - stroke: '#1e40af', - strokeWidth: 1, - cursor: 's-resize', - visibility: 'hidden', - pointerEvents: 'all' - }, - 'resize-w': { - ref: 'body', - refX: 0, - refY: '50%', - x: -4, - y: -4, - width: 8, - height: 8, - fill: '#3b82f6', - stroke: '#1e40af', - strokeWidth: 1, - cursor: 'w-resize', - visibility: 'hidden', - pointerEvents: 'all' - }, - 'resize-e': { - ref: 'body', - refX: '100%', - refY: '50%', - x: -4, - y: -4, - width: 8, - height: 8, - fill: '#3b82f6', - stroke: '#1e40af', - strokeWidth: 1, - cursor: 'e-resize', - visibility: 'hidden', - pointerEvents: 'all' - } - }, - markup: [ - { - tagName: 'rect', - selector: 'body' - }, - // Resize handles - { tagName: 'rect', selector: 'resize-nw' }, - { tagName: 'rect', selector: 'resize-ne' }, - { tagName: 'rect', selector: 'resize-sw' }, - { tagName: 'rect', selector: 'resize-se' }, - { tagName: 'rect', selector: 'resize-n' }, - { tagName: 'rect', selector: 'resize-s' }, - { tagName: 'rect', selector: 'resize-w' }, - { tagName: 'rect', selector: 'resize-e' } - ] - }; - } - - // Method to update square properties - updateSquareData(data: Partial) { - const currentData = this.get('data') || {}; - this.set('data', { ...currentData, ...data }); - this.updateSquareAttrs(); - } - - // Get current square data - getSquareData(): SquareElementData { - return this.get('data') || {}; - } - - // Show resize handles - showResizeHandles() { - const handles = ['resize-nw', 'resize-ne', 'resize-sw', 'resize-se', 'resize-n', 'resize-s', 'resize-w', 'resize-e']; - handles.forEach(handle => { - this.attr(`${handle}/visibility`, 'visible'); - }); - - // Update data to track selection state - const currentData = this.get('data') || {}; - this.set('data', { ...currentData, isSelected: true }); - } - - // Hide resize handles - hideResizeHandles() { - const handles = ['resize-nw', 'resize-ne', 'resize-sw', 'resize-se', 'resize-n', 'resize-s', 'resize-w', 'resize-e']; - handles.forEach(handle => { - this.attr(`${handle}/visibility`, 'hidden'); - }); - - // Update data to track selection state - const currentData = this.get('data') || {}; - this.set('data', { ...currentData, isSelected: false }); - } - - // Check if resize handles are visible - areResizeHandlesVisible(): boolean { - const data = this.get('data') as SquareElementData || {}; - return data.isSelected || false; - } - - // Get the resize handle that was clicked - getResizeHandle(target: HTMLElement): string | null { - // Check if the target itself has the selector - const selector = target.getAttribute('data-selector'); - if (selector && selector.startsWith('resize-')) { - return selector; - } - - // Check parent elements for the selector - let currentElement = target.parentElement; - while (currentElement) { - const parentSelector = currentElement.getAttribute('data-selector'); - if (parentSelector && parentSelector.startsWith('resize-')) { - return parentSelector; - } - currentElement = currentElement.parentElement; - } - - // Alternative approach: check the SVG element class or tag - const tagName = target.tagName?.toLowerCase(); - if (tagName === 'rect') { - // Check if this rect is one of our resize handles - const parent = target.parentElement; - if (parent) { - // Look for JointJS generated elements with our selector - const allRects = parent.querySelectorAll('rect[data-selector^="resize-"]'); - for (let i = 0; i < allRects.length; i++) { - if (allRects[i] === target) { - return (allRects[i] as HTMLElement).getAttribute('data-selector'); - } - } - } - } - - return null; - } -} diff --git a/Website/components/diagramview/elements/SquareElementView.ts b/Website/components/diagramview/elements/SquareElementView.ts deleted file mode 100644 index 2d3ec4a..0000000 --- a/Website/components/diagramview/elements/SquareElementView.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { dia } from '@joint/core'; - -export class SquareElementView extends dia.ElementView { - pointermove(evt: dia.Event, x: number, y: number): void { - // Check if we're in resize mode by looking at element data - const element = this.model; - const data = element.get('data') || {}; - - if (data.isSelected) { - // Don't allow normal dragging when resize handles are visible - return; - } - - // For unselected elements, use normal behavior - super.pointermove(evt, x, y); - } - - pointerdown(evt: dia.Event, x: number, y: number): void { - const target = evt.target as HTMLElement; - - // Check if clicking on a resize handle - let selector = target.getAttribute('joint-selector'); - if (!selector) { - let parent = target.parentElement; - let depth = 0; - while (parent && depth < 3) { - selector = parent.getAttribute('joint-selector'); - if (selector) break; - parent = parent.parentElement; - depth++; - } - } - - if (selector && selector.startsWith('resize-')) { - // For resize handles, don't start drag but allow event to bubble - return; - } - - // For all other clicks, use normal behavior - super.pointerdown(evt, x, y); - } -} diff --git a/Website/components/diagramview/elements/TextElement.ts b/Website/components/diagramview/elements/TextElement.ts deleted file mode 100644 index 64eb78e..0000000 --- a/Website/components/diagramview/elements/TextElement.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { dia, shapes, util } from '@joint/core'; - -// Helper function to measure text width -function measureTextWidth(text: string, fontSize: number, fontFamily: string): number { - // Create a temporary canvas element to measure text - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (!context) return 8; // fallback value - - context.font = `${fontSize}px ${fontFamily}`; - const metrics = context.measureText(text); - return metrics.width / text.length; // return average character width -} - -export interface TextElementData { - text: string; - fontSize: number; - fontFamily: string; - color: string; - backgroundColor: string; - padding: number; - borderRadius: number; - textAlign: 'left' | 'center' | 'right'; -} - -export class TextElement extends shapes.standard.Rectangle { - - defaults() { - return util.defaultsDeep({ - type: 'delegate.text', - size: { width: 200, height: 40 }, - attrs: { - root: { - magnetSelector: 'false' - }, - body: { - fill: 'transparent', - stroke: 'transparent', - strokeWidth: 0, - rx: 4, - ry: 4 - }, - label: { - text: 'Text Element', - fontSize: 14, - fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - fill: '#000000', - textAnchor: 'start', - textVerticalAnchor: 'top', - x: 8, - y: 8 - } - } - }, super.defaults); - } - - constructor(attributes?: dia.Element.Attributes, options?: dia.Graph.Options) { - super(attributes, options); - - // Set initial data if provided - const initialData: TextElementData = { - text: 'Text Element', - fontSize: 14, - fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - color: '#000000', - backgroundColor: 'transparent', - padding: 8, - borderRadius: 4, - textAlign: 'left', - ...attributes?.data - }; - - this.set('data', initialData); - this.updateTextElement(initialData); - } - - updateTextElement(data: TextElementData) { - // Update the visual appearance based on data - this.attr({ - body: { - fill: data.backgroundColor, - rx: data.borderRadius, - ry: data.borderRadius - }, - label: { - text: data.text, - fontSize: data.fontSize, - fontFamily: data.fontFamily, - fill: data.color, - textAnchor: this.getTextAnchor(data.textAlign), - textVerticalAnchor: 'top', - x: this.getTextX(data.textAlign, data.padding), - y: data.padding - } - }); - - // Adjust element size based on text content - this.adjustSizeToText(data); - } - - private getTextAnchor(textAlign: 'left' | 'center' | 'right'): string { - switch (textAlign) { - case 'center': return 'middle'; - case 'right': return 'end'; - default: return 'start'; - } - } - - private getTextX(textAlign: 'left' | 'center' | 'right', padding: number): number { - const size = this.size(); - switch (textAlign) { - case 'center': return size.width / 2; - case 'right': return size.width - padding; - default: return padding; - } - } - - private adjustSizeToText(data: TextElementData) { - const charWidth = measureTextWidth(data.text, data.fontSize, data.fontFamily); - const textWidth = data.text.length * charWidth; - const minWidth = Math.max(textWidth + (data.padding * 2), 100); - const minHeight = Math.max(data.fontSize + (data.padding * 2), 30); - - this.resize(minWidth, minHeight); - } - - getTextData(): TextElementData { - return this.get('data') || {}; - } - - updateTextData(newData: Partial) { - const currentData = this.getTextData(); - const updatedData = { ...currentData, ...newData }; - this.set('data', updatedData); - this.updateTextElement(updatedData); - } -} - -// Register the custom element -(shapes as Record).delegate = { - ...((shapes as Record).delegate || {}), - text: TextElement -}; diff --git a/Website/components/diagramview/panes/AddAttributePane.tsx b/Website/components/diagramview/panes/AddAttributePane.tsx deleted file mode 100644 index f68be18..0000000 --- a/Website/components/diagramview/panes/AddAttributePane.tsx +++ /dev/null @@ -1,164 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - DialogActions, - Button, - TextField, - Card, - CardContent, - Typography, - Tooltip -} from '@mui/material'; -import { AttributeType } from '@/lib/Types'; -import { AssignmentRounded, AttachmentRounded, AttachMoneyRounded, CalendarMonthRounded, ListRounded, NumbersRounded, RttRounded, SearchRounded, ToggleOffRounded } from '@mui/icons-material'; - -export interface AddAttributePaneProps { - isOpen: boolean; - onClose: () => void; - onAddAttribute: (attribute: AttributeType) => void; - entityName?: string; - availableAttributes: AttributeType[]; - visibleAttributes: AttributeType[]; -} - -const getAttributeIcon = (attributeType: string) => { - switch (attributeType) { - case 'StringAttribute': return RttRounded; - case 'IntegerAttribute': return NumbersRounded; - case 'DecimalAttribute': return AttachMoneyRounded; - case 'DateTimeAttribute': return CalendarMonthRounded; - case 'BooleanAttribute': return ToggleOffRounded; - case 'ChoiceAttribute': return ListRounded; - case 'LookupAttribute': return SearchRounded; - case 'FileAttribute': return AttachmentRounded; - case 'StatusAttribute': return AssignmentRounded; - default: return RttRounded; - } -}; - -const getAttributeTypeLabel = (attributeType: string) => { - switch (attributeType) { - case 'StringAttribute': return 'Text'; - case 'IntegerAttribute': return 'Number (Whole)'; - case 'DecimalAttribute': return 'Number (Decimal)'; - case 'DateTimeAttribute': return 'Date & Time'; - case 'BooleanAttribute': return 'Yes/No'; - case 'ChoiceAttribute': return 'Choice'; - case 'LookupAttribute': return 'Lookup'; - case 'FileAttribute': return 'File'; - case 'StatusAttribute': return 'Status'; - default: return attributeType.replace('Attribute', ''); - } -}; - -export const AddAttributePane: React.FC = ({ - isOpen, - onClose, - onAddAttribute, - entityName, - availableAttributes, - visibleAttributes -}) => { - const [searchQuery, setSearchQuery] = useState(''); - - // Filter out attributes that are already visible in the diagram - const visibleAttributeNames = visibleAttributes.map(attr => attr.SchemaName); - const addableAttributes = availableAttributes.filter(attr => - !visibleAttributeNames.includes(attr.SchemaName) && - attr.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const handleAddAttribute = (attribute: AttributeType) => { - onAddAttribute(attribute); - setSearchQuery(''); - onClose(); - }; - - return ( - - - Add Existing Attribute ({availableAttributes.length}) - - {entityName ? `Select an attribute from "${entityName}" to add to the diagram.` : 'Select an attribute to add to the diagram.'} - - -
- {/* Search */} -
- Search Attributes - ) => setSearchQuery(e.target.value)} - placeholder="Search by attribute name..." - /> -
- - {/* Available Attributes */} -
- Available Attributes ({addableAttributes.length}) -
-
- {addableAttributes.length === 0 ? ( -
- {searchQuery ? 'No attributes found matching your search.' : 'No attributes available to add.'} -
- ) : ( -
- {addableAttributes.map((attribute) => { - const AttributeIcon = getAttributeIcon(attribute.AttributeType); - const typeLabel = getAttributeTypeLabel(attribute.AttributeType); - - return ( - handleAddAttribute(attribute)} - sx={{ cursor: 'pointer', '&:hover': { backgroundColor: 'action.hover' } }} - > - -
-
- -
-
-
- {attribute.DisplayName} -
-
- {typeLabel} • {attribute.SchemaName} -
-
- {attribute.Description && ( - -
- ? -
-
- )} -
-
-
- ); - })} -
- )} -
-
-
-
- - - - -
-
- ); -}; diff --git a/Website/components/diagramview/panes/AddEntityPane.tsx b/Website/components/diagramview/panes/AddEntityPane.tsx deleted file mode 100644 index 1ebdaca..0000000 --- a/Website/components/diagramview/panes/AddEntityPane.tsx +++ /dev/null @@ -1,259 +0,0 @@ -'use client'; - -import React, { useState, useMemo } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Checkbox, - Typography, - FormControlLabel -} from '@mui/material'; -import { Groups } from '@/generated/Data'; -import { EntityType, GroupType, AttributeType } from '@/lib/Types'; -import { useAttributeSelection } from '@/hooks/useAttributeSelection'; -import { AttributeSelectionPanel } from './AttributeSelectionPanel'; -import { SearchRounded } from '@mui/icons-material'; - -export interface AddEntityPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onAddEntity: (entity: EntityType, selectedAttributes?: string[]) => void; - currentEntities: EntityType[]; -} - -export const AddEntityPane: React.FC = ({ - isOpen, - onOpenChange, - onAddEntity, - currentEntities -}) => { - const [searchTerm, setSearchTerm] = useState(''); - const [selectedEntity, setSelectedEntity] = useState(null); - const [isAttributeSettingsExpanded, setIsAttributeSettingsExpanded] = useState(false); - - const { - attributeMode, - setAttributeMode, - customSelectedAttributes, - getSelectedAttributes, - initializeCustomAttributes, - toggleCustomAttribute, - resetCustomAttributes, - getAttributeModeDescription, - } = useAttributeSelection('custom-lookups'); - - // Filter groups and entities based on search term - const filteredData = useMemo(() => { - if (!searchTerm.trim()) { - return Groups; - } - - const lowerSearchTerm = searchTerm.toLowerCase(); - return Groups.map(group => ({ - ...group, - Entities: group.Entities.filter(entity => - entity.DisplayName.toLowerCase().includes(lowerSearchTerm) || - entity.SchemaName.toLowerCase().includes(lowerSearchTerm) || - group.Name.toLowerCase().includes(lowerSearchTerm) - ) - })).filter(group => - group.Name.toLowerCase().includes(lowerSearchTerm) || - group.Entities.length > 0 - ); - }, [searchTerm]); - - const handleAddEntity = (entity: EntityType) => { - const selectedAttributes = getSelectedAttributes(entity); - onAddEntity(entity, selectedAttributes); - onOpenChange(false); - setSelectedEntity(null); - resetCustomAttributes(); - }; - - const handleEntityClick = (entity: EntityType) => { - if (attributeMode === 'custom') { - setSelectedEntity(entity); - initializeCustomAttributes(entity); - } else { - handleAddEntity(entity); - } - }; - - const handleCustomAttributeToggle = (attributeSchemaName: string, checked: boolean) => { - toggleCustomAttribute(attributeSchemaName, checked); - }; - - return ( - onOpenChange(false)} maxWidth="md" fullWidth> - - Add Entity to Diagram -
- {/* Attribute Selection Options */} - - - {/* Search Input */} -
- - ) => setSearchTerm(e.target.value)} - sx={{ pl: '40px' }} - InputProps={{ style: { paddingLeft: '40px' } }} - /> -
- - {/* Groups and Entities List */} - {!selectedEntity ? ( -
- {filteredData.map((group: GroupType) => ( -
- - {group.Name} - -
- {group.Entities.map((entity: EntityType) => { - const isAlreadyInDiagram = currentEntities.some(e => e.SchemaName === entity.SchemaName); - return ( - - ); - })} -
-
- ))} - {filteredData.length === 0 && ( -
- No entities found matching your search. -
- )} -
- ) : ( - /* Custom Attribute Selection View */ -
-
-
- Configure {selectedEntity.DisplayName} - Select attributes to include -
- -
- -
- {selectedEntity.Attributes.map((attribute: AttributeType) => { - const isChecked = customSelectedAttributes.includes(attribute.SchemaName); - const isPrimaryKey = attribute.IsPrimaryId; - - return ( -
- ) => - handleCustomAttributeToggle(attribute.SchemaName, e.target.checked) - } - /> - } - label="" - /> -
-
- {attribute.DisplayName} - {isPrimaryKey && ( - - Primary Key - - )} - {attribute.AttributeType === "LookupAttribute" && ( - - Lookup - - )} -
-

{attribute.SchemaName}

- {attribute.Description && ( -

{attribute.Description}

- )} -
-
- ); - })} -
- -
- -
-
- )} -
-
-
- ); -}; diff --git a/Website/components/diagramview/panes/AddGroupPane.tsx b/Website/components/diagramview/panes/AddGroupPane.tsx deleted file mode 100644 index 872b743..0000000 --- a/Website/components/diagramview/panes/AddGroupPane.tsx +++ /dev/null @@ -1,187 +0,0 @@ -'use client'; - -import React, { useState, useMemo } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Typography -} from '@mui/material'; -import { Groups } from '@/generated/Data'; -import { EntityType, GroupType } from '@/lib/Types'; -import { useAttributeSelection } from '@/hooks/useAttributeSelection'; -import { AttributeSelectionPanel } from './AttributeSelectionPanel'; -import { FolderRounded, SearchRounded } from '@mui/icons-material'; - -export interface AddGroupPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onAddGroup: (group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => void; - currentEntities: EntityType[]; -} - -export const AddGroupPane: React.FC = ({ - isOpen, - onOpenChange, - onAddGroup, - currentEntities -}) => { - const [searchTerm, setSearchTerm] = useState(''); - const [isAttributeSettingsExpanded, setIsAttributeSettingsExpanded] = useState(false); - - const { - attributeMode, - setAttributeMode, - getSelectedAttributes, - getAttributeModeDescription, - } = useAttributeSelection('custom-lookups'); - - // Filter groups based on search term - const filteredGroups = useMemo(() => { - if (!searchTerm.trim()) { - return Groups; - } - - const lowerSearchTerm = searchTerm.toLowerCase(); - return Groups.filter(group => - group.Name.toLowerCase().includes(lowerSearchTerm) - ); - }, [searchTerm]); - - const handleAddGroup = (group: GroupType) => { - // Create attribute selection map for all entities in the group - const selectedAttributes: { [entitySchemaName: string]: string[] } = {}; - - group.Entities.forEach(entity => { - selectedAttributes[entity.SchemaName] = getSelectedAttributes(entity); - }); - - onAddGroup(group, selectedAttributes); - onOpenChange(false); - }; - - // Calculate how many entities from each group are already in the diagram - const getGroupStatus = (group: GroupType) => { - const entitiesInDiagram = group.Entities.filter(entity => - currentEntities.some(e => e.SchemaName === entity.SchemaName) - ).length; - const totalEntities = group.Entities.length; - return { entitiesInDiagram, totalEntities }; - }; - - return ( - onOpenChange(false)} maxWidth="md" fullWidth> - - Add Group to Diagram -
- {/* Attribute Selection Options */} - - - {/* Search Input */} -
- - ) => setSearchTerm(e.target.value)} - slotProps={{ input: { style: { paddingLeft: '40px' } } }} - /> -
- - {/* Groups List */} -
- {filteredGroups.map((group: GroupType) => { - const { entitiesInDiagram, totalEntities } = getGroupStatus(group); - const isFullyInDiagram = entitiesInDiagram === totalEntities && totalEntities > 0; - const isPartiallyInDiagram = entitiesInDiagram > 0 && entitiesInDiagram < totalEntities; - - return ( -
-
-
- -
- {group.Name} - - {group.Entities.length} entities - -
-
-
- - {entitiesInDiagram}/{totalEntities} entities - - {isFullyInDiagram && ( - - All in Diagram - - )} - {isPartiallyInDiagram && ( - - Partially Added - - )} -
-
- -
- {group.Entities.slice(0, 5).map((entity: EntityType) => { - const isInDiagram = currentEntities.some(e => e.SchemaName === entity.SchemaName); - return ( - - {entity.DisplayName} - - ); - })} - {group.Entities.length > 5 && ( - - +{group.Entities.length - 5} more - - )} -
- - -
- ); - })} - {filteredGroups.length === 0 && ( -
- No groups found matching your search. -
- )} -
-
-
-
- ); -}; diff --git a/Website/components/diagramview/panes/AttributeSelectionPanel.tsx b/Website/components/diagramview/panes/AttributeSelectionPanel.tsx deleted file mode 100644 index 7b5a150..0000000 --- a/Website/components/diagramview/panes/AttributeSelectionPanel.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import React from 'react'; -import { - Button, - Collapse, - RadioGroup, - FormControlLabel, - Radio, - Typography, - Box -} from '@mui/material'; -import { AttributeSelectionMode } from '@/hooks/useAttributeSelection'; -import { ChevronRightRounded, ExpandRounded, SettingsRounded } from '@mui/icons-material'; - -export interface AttributeSelectionPanelProps { - attributeMode: AttributeSelectionMode; - setAttributeMode: (mode: AttributeSelectionMode) => void; - isExpanded: boolean; - setIsExpanded: (expanded: boolean) => void; - getAttributeModeDescription: (mode: AttributeSelectionMode) => string; -} - -export const AttributeSelectionPanel: React.FC = ({ - attributeMode, - setAttributeMode, - isExpanded, - setIsExpanded, - getAttributeModeDescription -}) => { - return ( - - - - - - - Default attributes to include: - - ) => setAttributeMode(e.target.value as AttributeSelectionMode)} - > - } - label={ - - {getAttributeModeDescription('minimal')} - - } - /> - } - label={ - - {getAttributeModeDescription('custom-lookups')} - - } - /> - } - label={ - - {getAttributeModeDescription('all-lookups')} - - } - /> - } - label={ - - {getAttributeModeDescription('custom')} - - } - /> - - - - - ); -}; diff --git a/Website/components/diagramview/panes/EntityActionsPane.tsx b/Website/components/diagramview/panes/EntityActionsPane.tsx deleted file mode 100644 index c01f8ad..0000000 --- a/Website/components/diagramview/panes/EntityActionsPane.tsx +++ /dev/null @@ -1,319 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Collapse, - Box, - Typography, - Card, - CardContent, - Tooltip -} from '@mui/material'; -import { EntityType, AttributeType } from '@/lib/Types'; -import { RttRounded, NumbersRounded, AttachMoneyRounded, CalendarMonthRounded, ToggleOffRounded, ListRounded, SearchRounded, AttachmentRounded, AssignmentRounded, ChevronRight, AddRounded, ExpandRounded, DeleteRounded } from '@mui/icons-material'; - -export interface EntityActionsPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - selectedEntity: EntityType | null; - onDeleteEntity: () => void; - onAddAttribute?: (attribute: AttributeType) => void; - onRemoveAttribute?: (attribute: AttributeType) => void; - availableAttributes?: AttributeType[]; - visibleAttributes?: AttributeType[]; -} - -const getAttributeIcon = (attributeType: string) => { - switch (attributeType) { - case 'StringAttribute': return RttRounded; - case 'IntegerAttribute': return NumbersRounded; - case 'DecimalAttribute': return AttachMoneyRounded; - case 'DateTimeAttribute': return CalendarMonthRounded; - case 'BooleanAttribute': return ToggleOffRounded; - case 'ChoiceAttribute': return ListRounded; - case 'LookupAttribute': return SearchRounded; - case 'FileAttribute': return AttachmentRounded; - case 'StatusAttribute': return AssignmentRounded; - default: return RttRounded; - } -}; - -const getAttributeTypeLabel = (attributeType: string) => { - switch (attributeType) { - case 'StringAttribute': return 'Text'; - case 'IntegerAttribute': return 'Number (Whole)'; - case 'DecimalAttribute': return 'Number (Decimal)'; - case 'DateTimeAttribute': return 'Date & Time'; - case 'BooleanAttribute': return 'Yes/No'; - case 'ChoiceAttribute': return 'Choice'; - case 'LookupAttribute': return 'Lookup'; - case 'FileAttribute': return 'File'; - case 'StatusAttribute': return 'Status'; - default: return attributeType.replace('Attribute', ''); - } -}; - -export const EntityActionsPane: React.FC = ({ - isOpen, - onOpenChange, - selectedEntity, - onDeleteEntity, - onAddAttribute, - onRemoveAttribute, - availableAttributes = [], - visibleAttributes = [] -}) => { - const [searchQuery, setSearchQuery] = useState(''); - const [isAttributesExpanded, setIsAttributesExpanded] = useState(false); - const [isRemoveAttributesExpanded, setIsRemoveAttributesExpanded] = useState(false); - - // Filter out attributes that are already visible in the diagram - const visibleAttributeNames = visibleAttributes.map(attr => attr.SchemaName); - const addableAttributes = availableAttributes.filter(attr => - !visibleAttributeNames.includes(attr.SchemaName) && - attr.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const handleAddAttribute = (attribute: AttributeType) => { - if (onAddAttribute) { - onAddAttribute(attribute); - setSearchQuery(''); - setIsAttributesExpanded(false); - } - }; - - const handleRemoveAttribute = (attribute: AttributeType) => { - if (onRemoveAttribute) { - onRemoveAttribute(attribute); - } - }; - - // Filter removable attributes (exclude primary key) - const removableAttributes = visibleAttributes.filter(attr => - !attr.IsPrimaryId // Don't allow removing primary key - all other visible attributes can be removed - ); - - return ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - {selectedEntity && ( - <> - {selectedEntity.DisplayName} - -
-
- - {selectedEntity.SchemaName} - - {selectedEntity.Description && ( - - {selectedEntity.Description} - - )} -
- -
-
- Actions - - {/* Add Attribute Section */} - {onAddAttribute && availableAttributes.length > 0 && ( - - - - - - {/* Search */} - ) => setSearchQuery(e.target.value)} - placeholder="Search attributes..." - sx={{ mb: 2 }} - /> - - {/* Available Attributes */} - - {addableAttributes.length === 0 ? ( - - - {searchQuery ? 'No attributes found.' : 'No attributes available.'} - - - ) : ( - - {addableAttributes.map((attribute) => { - const AttributeIcon = getAttributeIcon(attribute.AttributeType); - const typeLabel = getAttributeTypeLabel(attribute.AttributeType); - - return ( - handleAddAttribute(attribute)} - > - - - - - - {attribute.DisplayName} - - - {typeLabel} - - - {attribute.Description && ( - - - ? - - - )} - - - - ); - })} - - )} - - - - - )} - - {/* Remove Attribute Section */} - {onRemoveAttribute && removableAttributes.length > 0 && ( - - - - - - - {removableAttributes.map((attribute) => { - const AttributeIcon = getAttributeIcon(attribute.AttributeType); - const typeLabel = getAttributeTypeLabel(attribute.AttributeType); - - return ( - handleRemoveAttribute(attribute)} - > - - - - - - {attribute.DisplayName} - - - {typeLabel} - - - - - - - ); - })} - - - Note: Primary key cannot be removed. - - - - - )} - - -
-
- -
-
- Entity Information -
- - Attributes: {selectedEntity.Attributes.length} - - - Relationships: {selectedEntity.Relationships?.length || 0} - - - Is Activity: {selectedEntity.IsActivity ? 'Yes' : 'No'} - - - Audit Enabled: {selectedEntity.IsAuditEnabled ? 'Yes' : 'No'} - -
-
-
-
- - )} -
-
- ); -}; diff --git a/Website/components/diagramview/panes/LinkPropertiesPane.tsx b/Website/components/diagramview/panes/LinkPropertiesPane.tsx deleted file mode 100644 index 2e9d486..0000000 --- a/Website/components/diagramview/panes/LinkPropertiesPane.tsx +++ /dev/null @@ -1,237 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { dia } from '@joint/core'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Select, - MenuItem, - FormControl, - InputLabel, - Typography, - Box, - Divider -} from '@mui/material'; -import { PRESET_COLORS, LINE_STYLES, STROKE_WIDTHS } from '../shared/DiagramConstants'; - -interface LinkPropertiesPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - selectedLink: dia.Link | null; - onUpdateLink: (linkId: string | number, properties: LinkProperties) => void; -} - -export type { LinkPropertiesPaneProps }; - -export interface LinkProperties { - color: string; - strokeWidth: number; - strokeDasharray?: string; - label?: string; -} - -export const LinkPropertiesPane: React.FC = ({ - isOpen, - onOpenChange, - selectedLink, - onUpdateLink -}) => { - const [color, setColor] = useState(PRESET_COLORS.borders[1].value); // Default to Blue - const [strokeWidth, setStrokeWidth] = useState(2); - const [lineStyle, setLineStyle] = useState('none'); - const [label, setLabel] = useState(''); - const [customColor, setCustomColor] = useState(PRESET_COLORS.borders[1].value); - - // Load current link properties when selectedLink changes - useEffect(() => { - if (selectedLink) { - const currentColor = selectedLink.attr('line/stroke') || PRESET_COLORS.borders[1].value; - const currentStrokeWidth = selectedLink.attr('line/strokeWidth') || 2; - const currentDasharray = selectedLink.attr('line/strokeDasharray') || 'none'; - const currentLabel = selectedLink.label(0)?.attrs?.text?.text || ''; - - setColor(currentColor); - setCustomColor(currentColor); - setStrokeWidth(currentStrokeWidth); - setLineStyle(currentDasharray === '' ? 'none' : currentDasharray); - setLabel(currentLabel); - } - }, [selectedLink]); - - // Apply changes immediately when any property changes - useEffect(() => { - if (selectedLink) { - const properties: LinkProperties = { - color, - strokeWidth, - strokeDasharray: lineStyle && lineStyle !== 'none' ? lineStyle : undefined, - label: label || undefined - }; - onUpdateLink(selectedLink.id, properties); - } - }, [color, strokeWidth, lineStyle, label, selectedLink, onUpdateLink]); - - const handleClearLabel = () => { - setLabel(''); - }; - - const handleUseRelationshipName = () => { - const relationshipName = getRelationshipName(); - if (relationshipName) { - setLabel(relationshipName); - } - }; - - const getRelationshipName = () => { - if (!selectedLink) return null; - - // Try to get the relationship name stored on the link - const relationshipName = selectedLink.get('relationshipName'); - return relationshipName || null; - }; - - const handleColorChange = (newColor: string) => { - setColor(newColor); - setCustomColor(newColor); - }; - - return ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - Link Properties - - Customize the appearance and label of the relationship link. - - - - {/* Label Section */} - - Link Label - ) => setLabel(e.target.value)} - /> - - - - - - Optional text to display on the link - - - - - - {/* Color Section */} - - Link Color - - {PRESET_COLORS.borders.map((presetColor) => ( - - ))} - - - - ) => handleColorChange(e.target.value)} - sx={{ width: 48, '& .MuiInputBase-input': { p: 0.5 } }} - size="small" - /> - ) => handleColorChange(e.target.value)} - placeholder="#3b82f6" - /> - - - - - - {/* Line Style Section */} - - - Line Style - - - - - {/* Stroke Width Section */} - - - Line Thickness - - - - - - - ); -}; diff --git a/Website/components/diagramview/panes/ResetToGroupPane.tsx b/Website/components/diagramview/panes/ResetToGroupPane.tsx deleted file mode 100644 index 47ec023..0000000 --- a/Website/components/diagramview/panes/ResetToGroupPane.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useState } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - DialogActions, - Button, - Select, - MenuItem, - FormControl, - InputLabel, - Typography, - Box -} from '@mui/material'; -import { Groups } from '../../../generated/Data'; -import { GroupType } from '@/lib/Types'; -import { RefreshRounded } from '@mui/icons-material'; - -interface IResetToGroupPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onResetToGroup: (group: GroupType) => void; -} - -export const ResetToGroupPane = ({ isOpen, onOpenChange, onResetToGroup }: IResetToGroupPaneProps) => { - const [selectedGroupForReset, setSelectedGroupForReset] = useState(''); - - const handleResetToGroup = () => { - if (!selectedGroupForReset) return; - - const selectedGroup = Groups.find(group => group.Name === selectedGroupForReset); - if (selectedGroup) { - onResetToGroup(selectedGroup); - onOpenChange(false); - setSelectedGroupForReset(''); - } - }; - - const handleCancel = () => { - onOpenChange(false); - setSelectedGroupForReset(''); - }; - - return ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - Reset Diagram to Group - - Choose a group to reset the diagram and show only entities from that group. - This will clear the current diagram and add all entities from the selected group. - - - - - Select Group - - - - - - Warning - - - This will clear all current elements from your diagram and replace them with entities from the selected group. - - - - - - - - - - - ); -}; diff --git a/Website/components/diagramview/panes/SquarePropertiesPane.tsx b/Website/components/diagramview/panes/SquarePropertiesPane.tsx deleted file mode 100644 index d3c01f8..0000000 --- a/Website/components/diagramview/panes/SquarePropertiesPane.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Typography, - Box, - Divider -} from '@mui/material'; -import { SquareElement, SquareElementData } from '../elements/SquareElement'; -import { PRESET_COLORS } from '../shared/DiagramConstants'; -import { DeleteRounded, SquareRounded } from '@mui/icons-material'; - -export interface SquarePropertiesPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - selectedSquare: SquareElement | null; - onDeleteSquare?: () => void; -} - -export const SquarePropertiesPane: React.FC = ({ - isOpen, - onOpenChange, - selectedSquare, - onDeleteSquare -}) => { - const [squareData, setSquareData] = useState({ - borderColor: PRESET_COLORS.borders[0].value, - fillColor: PRESET_COLORS.fills[0].value, - borderWidth: 2, - borderType: 'dashed', - opacity: 0.7 - }); - - // Update local state when selected square changes - useEffect(() => { - if (selectedSquare) { - const data = selectedSquare.getSquareData(); - setSquareData({ - borderColor: data.borderColor || PRESET_COLORS.borders[0].value, - fillColor: data.fillColor || PRESET_COLORS.fills[0].value, - borderWidth: data.borderWidth || 2, - borderType: data.borderType || 'dashed', - opacity: data.opacity || 0.7 - }); - } - }, [selectedSquare]); - - const handleDataChange = (key: keyof SquareElementData, value: string | number) => { - const newData = { ...squareData, [key]: value }; - setSquareData(newData); - - // Apply changes immediately to the square - if (selectedSquare) { - selectedSquare.updateSquareData(newData); - } - }; - - const handlePresetFillColor = (color: string) => { - handleDataChange('fillColor', color); - }; - - const handlePresetBorderColor = (color: string) => { - handleDataChange('borderColor', color); - }; - - const handleDeleteSquare = () => { - if (selectedSquare && onDeleteSquare) { - onDeleteSquare(); - onOpenChange(false); // Close the panel after deletion - } - }; - - if (!selectedSquare) { - return null; - } - - return ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - - Square Properties - - - - {/* Fill Color Section */} - - - Fill Color - - - {PRESET_COLORS.fills.map((color) => ( - - - - - - ); -}; diff --git a/Website/components/diagramview/panes/TextPropertiesPane.tsx b/Website/components/diagramview/panes/TextPropertiesPane.tsx deleted file mode 100644 index 5ba731e..0000000 --- a/Website/components/diagramview/panes/TextPropertiesPane.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Typography, - Box, - Divider -} from '@mui/material'; -import { TextElement, TextElementData } from '../elements/TextElement'; -import { DeleteRounded, RttRounded } from '@mui/icons-material'; - -export interface TextPropertiesPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - selectedText: TextElement | null; - onDeleteText?: () => void; -} - -const FONT_SIZES = [ - { name: 'Small', value: 12 }, - { name: 'Normal', value: 14 }, - { name: 'Medium', value: 16 }, - { name: 'Large', value: 20 }, - { name: 'Extra Large', value: 24 } -]; - -const FONT_FAMILIES = [ - { name: 'Roboto', value: 'Roboto, sans-serif' }, - { name: 'System Font', value: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' }, - { name: 'Arial', value: 'Arial, sans-serif' }, - { name: 'Helvetica', value: 'Helvetica, Arial, sans-serif' }, - { name: 'Times', value: 'Times, "Times New Roman", serif' }, - { name: 'Courier', value: 'Courier, "Courier New", monospace' } -]; - -export const TextPropertiesPane: React.FC = ({ - isOpen, - onOpenChange, - selectedText, - onDeleteText -}) => { - const [textData, setTextData] = useState({ - text: 'Text Element', - fontSize: 14, - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - color: '#000000', - backgroundColor: 'transparent', - padding: 8, - borderRadius: 4, - textAlign: 'left' - }); - - // Update local state when selected text changes - useEffect(() => { - if (selectedText) { - const data = selectedText.getTextData(); - setTextData({ - text: data.text || 'Text Element', - fontSize: data.fontSize || 14, - fontFamily: data.fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - color: data.color || '#000000', - backgroundColor: data.backgroundColor || 'transparent', - padding: data.padding || 8, - borderRadius: data.borderRadius || 4, - textAlign: data.textAlign || 'left' - }); - } - }, [selectedText]); - - // Apply changes immediately when any property changes - useEffect(() => { - if (selectedText) { - selectedText.updateTextData(textData); - } - }, [textData, selectedText]); - - const handleDataChange = (key: keyof TextElementData, value: string | number) => { - setTextData(prev => ({ ...prev, [key]: value })); - }; - - const handleDeleteText = () => { - if (selectedText && onDeleteText) { - onDeleteText(); - onOpenChange(false); - } - }; - - if (!selectedText) { - return null; - } - - return ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - - Text Properties - - - - {/* Text Content */} - - - Text Content - - ) => handleDataChange('text', e.target.value)} - fullWidth - size="small" - /> - - - - - {/* Typography */} - - - Typography - - - {/* Font Family */} - - - Font Family - - ) => handleDataChange('fontFamily', e.target.value)} - size="small" - fullWidth - SelectProps={{ native: true }} - > - {FONT_FAMILIES.map((font) => ( - - ))} - - - - {/* Font Size */} - - - Font Size - - ) => handleDataChange('fontSize', parseInt(e.target.value))} - size="small" - fullWidth - SelectProps={{ native: true }} - > - {FONT_SIZES.map((size) => ( - - ))} - - - - {/* Text Alignment */} - - - Text Alignment - - ) => handleDataChange('textAlign', e.target.value as 'left' | 'center' | 'right')} - size="small" - fullWidth - SelectProps={{ native: true }} - > - - - - - - - - - - {/* Colors */} - - - Colors - - - {/* Text Color */} - - - Text Color - - - ) => handleDataChange('color', e.target.value)} - sx={{ width: 48, '& .MuiInputBase-input': { padding: 0.5, height: 32 } }} - size="small" - /> - ) => handleDataChange('color', e.target.value)} - placeholder="#000000" - sx={{ flex: 1 }} - size="small" - /> - - - - {/* Background Color */} - - - Background Color - - - ) => handleDataChange('backgroundColor', e.target.value)} - sx={{ width: 48, '& .MuiInputBase-input': { padding: 0.5, height: 32 } }} - size="small" - /> - ) => handleDataChange('backgroundColor', e.target.value)} - placeholder="transparent" - sx={{ flex: 1 }} - size="small" - /> - - - - - - - {/* Layout */} - - - Layout - - - {/* Padding */} - - - Padding - - ) => handleDataChange('padding', parseInt(e.target.value) || 0)} - size="small" - fullWidth - /> - - - {/* Border Radius */} - - - Border Radius - - ) => handleDataChange('borderRadius', parseInt(e.target.value) || 0)} - size="small" - fullWidth - /> - - - - - - {/* Delete Section */} - {onDeleteText && ( - - - Danger Zone - - - - )} - - - - ); -}; diff --git a/Website/components/diagramview/panes/index.ts b/Website/components/diagramview/panes/index.ts deleted file mode 100644 index ff2ccf1..0000000 --- a/Website/components/diagramview/panes/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { AddEntityPane } from './AddEntityPane'; -export { AddGroupPane } from './AddGroupPane'; -export { EntityActionsPane } from './EntityActionsPane'; -export { SquarePropertiesPane } from './SquarePropertiesPane'; -export { LinkPropertiesPane } from './LinkPropertiesPane'; -export { TextPropertiesPane } from './TextPropertiesPane'; -export { ResetToGroupPane } from './ResetToGroupPane'; - -export type { AddEntityPaneProps } from './AddEntityPane'; -export type { AddGroupPaneProps } from './AddGroupPane'; -export type { EntityActionsPaneProps } from './EntityActionsPane'; -export type { SquarePropertiesPaneProps } from './SquarePropertiesPane'; -export type { TextPropertiesPaneProps } from './TextPropertiesPane'; -export type { LinkPropertiesPaneProps, LinkProperties } from './LinkPropertiesPane'; diff --git a/Website/components/diagramview/renderers/DetailedDiagramRender.ts b/Website/components/diagramview/renderers/DetailedDiagramRender.ts deleted file mode 100644 index 9b209dc..0000000 --- a/Website/components/diagramview/renderers/DetailedDiagramRender.ts +++ /dev/null @@ -1,208 +0,0 @@ -// DetailedDiagramRender.ts -import { dia, shapes } from '@joint/core'; -import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; -import { EntityElement } from '../elements/EntityElement'; -import { AttributeType, EntityType } from '@/lib/Types'; - -export class DetailedDiagramRender extends DiagramRenderer { - - onDocumentClick(event: MouseEvent): void { - const target = (event.target as HTMLElement).closest('button[data-schema-name]') as HTMLElement; - - if (target) { - const schemaName = target.dataset.schemaName!; - // Toggle functionality: if clicking the same key, deselect it - const currentSelectedKey = this.getCurrentSelectedKey(); - if (currentSelectedKey === schemaName) { - this.setAndTrackSelectedKey(undefined); - } else { - this.setAndTrackSelectedKey(schemaName); - } - } else { - this.setAndTrackSelectedKey(undefined); - } - } - - createEntity(entity: EntityType, position: { x: number, y: number }) { - const { portMap } = EntityElement.getVisibleItemsAndPorts(entity); - const entityElement = new EntityElement({ - position, - data: { entity } - }); - - entityElement.addTo(this.graph); - return { element: entityElement, portMap }; - } - - createLinks(entity: EntityType, entityMap: Map, allEntities: EntityType[]) { - const entityInfo = entityMap.get(entity.SchemaName); - if (!entityInfo) return; - - const { portMap } = entityInfo; - const visibleItems = this.getVisibleAttributes(entity); - - for (let i = 1; i < visibleItems.length; i++) { - const attr = visibleItems[i]; - if (attr.AttributeType !== 'LookupAttribute') continue; - - for (const target of attr.Targets) { - const targetInfo = entityMap.get(target.Name); - if (!targetInfo) continue; - - const sourcePort = portMap[attr.SchemaName.toLowerCase()]; - const targetPort = targetInfo.portMap[`${target.Name.toLowerCase()}id`]; - if (!sourcePort || !targetPort) continue; - - // Find the corresponding relationship for this lookup attribute - // Check both source and target entities as the relationship could be defined on either side - let relationship = entity.Relationships.find(rel => - rel.TableSchema === target.Name && - rel.Name === attr.SchemaName - ); - - // If not found in source entity, check the target entity - if (!relationship) { - const targetEntity = allEntities.find(e => e.SchemaName === target.Name); - if (targetEntity) { - // Look for the reverse relationship in the target entity - relationship = targetEntity.Relationships.find(rel => - rel.TableSchema === entity.SchemaName - ); - } - } - - const link = new shapes.standard.Link({ - source: { id: entityInfo.element.id, port: sourcePort }, - target: { id: targetInfo.element.id, port: targetPort }, - router: { name: 'avoid', args: {} }, - connector: { name: 'jumpover', args: { radius: 8 } }, - attrs: { - line: { - stroke: '#42a5f5', - strokeWidth: 2, - sourceMarker: { - type: 'ellipse', - cx: -6, - cy: 0, - rx: 4, - ry: 4, - fill: '#fff', - stroke: '#42a5f5', - strokeWidth: 2, - }, - targetMarker: { - type: 'path', - d: 'M 6 -3 L 0 0 L 6 3 Z', - fill: '#42a5f5', - stroke: '#42a5f5' - } - } - } - }); - - // Store relationship metadata on the link - if (relationship) { - link.set('relationshipName', relationship.LookupDisplayName); - link.set('relationshipSchema', relationship.RelationshipSchema); - link.set('sourceEntity', entity.SchemaName); - link.set('targetEntity', target.Name); - } - - link.addTo(this.graph); - } - } - } - - highlightSelectedKey(graph: dia.Graph, entities: EntityType[], selectedKey: string): void { - // Find the attribute and its entity - let selectedAttribute: AttributeType | undefined; - let entityWithAttribute: EntityType | undefined; - - for (const entity of entities) { - const attribute = entity.Attributes.find(a => a.SchemaName === selectedKey); - if (attribute) { - selectedAttribute = attribute; - entityWithAttribute = entity; - break; - } - } - - if (!selectedAttribute || !entityWithAttribute) return; - - // Reset all links to default color first - graph.getLinks().forEach(link => { - link.attr('line/stroke', '#42a5f5'); - link.attr('line/strokeWidth', 2); - link.attr('line/targetMarker/stroke', '#42a5f5'); - link.attr('line/targetMarker/fill', '#42a5f5'); - link.attr('line/sourceMarker/stroke', '#42a5f5'); - }); - - // Find the entity element - const entityElement = graph.getElements().find(el => - el.get('type') === 'delegate.entity' && - el.get('data')?.entity?.SchemaName === entityWithAttribute.SchemaName - ); - - if (!entityElement) return; - - const portId = `port-${selectedKey.toLowerCase()}`; - - // Highlight different types of relationships based on attribute type - if (selectedAttribute.IsPrimaryId) { - // For primary keys, highlight incoming relationships (where this entity is the target) - graph.getLinks().forEach(link => { - const target = link.target(); - if (target.id === entityElement.id && target.port === portId) { - link.attr('line/stroke', '#ff6b6b'); - link.attr('line/strokeWidth', 4); - link.attr('line/targetMarker/stroke', '#ff6b6b'); - link.attr('line/targetMarker/fill', '#ff6b6b'); - link.attr('line/sourceMarker/stroke', '#ff6b6b'); - } - }); - } else if (selectedAttribute.AttributeType === 'LookupAttribute') { - // For lookup attributes, highlight outgoing relationships (where this entity is the source) - graph.getLinks().forEach(link => { - const source = link.source(); - if (source.id === entityElement.id && source.port === portId) { - link.attr('line/stroke', '#ff6b6b'); - link.attr('line/strokeWidth', 4); - link.attr('line/targetMarker/stroke', '#ff6b6b'); - link.attr('line/targetMarker/fill', '#ff6b6b'); - link.attr('line/sourceMarker/stroke', '#ff6b6b'); - } - }); - } - } - - updateEntityAttributes(graph: dia.Graph, selectedKey: string | undefined): void { - graph.getElements().forEach(el => { - if (el.get('type') === 'delegate.entity') { - const currentData = el.get('data'); - el.set('data', { ...currentData, selectedKey }); - - const entityElement = el as unknown as EntityElement; - if (entityElement.updateAttributes) { - entityElement.updateAttributes(currentData.entity); - } - } - }); - } - - onLinkClick(linkView: dia.LinkView, evt: dia.Event): void { - evt.stopPropagation(); - - const link = linkView.model as dia.Link; - if (this.onLinkClickHandler) { - this.onLinkClickHandler(link); - } else { - // Fallback alert if no handler is provided - alert('Relationship info (detailed view)'); - } - } - - getVisibleAttributes(entity: EntityType): AttributeType[] { - return EntityElement.getVisibleItemsAndPorts(entity).visibleItems; - } -} diff --git a/Website/components/diagramview/renderers/SimpleDiagramRender.ts b/Website/components/diagramview/renderers/SimpleDiagramRender.ts deleted file mode 100644 index f3dd680..0000000 --- a/Website/components/diagramview/renderers/SimpleDiagramRender.ts +++ /dev/null @@ -1,158 +0,0 @@ -// SimpleDiagramRenderer.ts -import { dia, shapes } from '@joint/core'; -import { SimpleEntityElement } from '@/components/diagramview/elements/SimpleEntityElement'; -import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; -import { AttributeType, EntityType } from '@/lib/Types'; - -export class SimpleDiagramRenderer extends DiagramRenderer { - - onDocumentClick(): void { } - - createEntity(entity: EntityType, position: { x: number, y: number }) { - const entityElement = new SimpleEntityElement({ - position, - data: { entity } - }); - - entityElement.addTo(this.graph); - - // 4-directional port map - const portMap = { - top: 'port-top', - right: 'port-right', - bottom: 'port-bottom', - left: 'port-left' - }; - - return { element: entityElement, portMap }; - } - - createLinks(entity: EntityType, entityMap: Map, allEntities: EntityType[]) { - const entityInfo = entityMap.get(entity.SchemaName); - if (!entityInfo) return; - - // Get visible attributes for this entity - const visibleAttributes = this.getVisibleAttributes(entity); - - for (const attr of visibleAttributes) { - if (attr.AttributeType !== 'LookupAttribute') continue; - - for (const target of attr.Targets) { - const targetInfo = entityMap.get(target.Name); - if (!targetInfo) continue; - - const isSelfRef = entityInfo.element.id === targetInfo.element.id; - - // Find the corresponding relationship for this lookup attribute - // Check both source and target entities as the relationship could be defined on either side - let relationship = entity.Relationships.find(rel => - rel.TableSchema === target.Name && - rel.Name === attr.SchemaName - ); - - // If not found in source entity, check the target entity - if (!relationship) { - const targetEntity = allEntities.find(e => e.SchemaName === target.Name); - if (targetEntity) { - // Look for the reverse relationship in the target entity - relationship = targetEntity.Relationships.find(rel => - rel.TableSchema === entity.SchemaName - ); - } - } - - const link = new shapes.standard.Link({ - source: isSelfRef - ? { id: entityInfo.element.id, port: entityInfo.portMap.right } - : { id: entityInfo.element.id }, - target: isSelfRef - ? { id: targetInfo.element.id, port: targetInfo.portMap.left } - : { id: targetInfo.element.id }, - router: { name: 'avoid', args: {} }, - connector: { name: 'jumpover', args: { radius: 8 } }, - attrs: { - line: { - stroke: '#42a5f5', - strokeWidth: 2, - sourceMarker: { - type: 'ellipse', - cx: -6, - cy: 0, - rx: 4, - ry: 4, - fill: '#fff', - stroke: '#42a5f5', - strokeWidth: 2, - }, - targetMarker: { - type: 'path', - d: 'M 6 -3 L 0 0 L 6 3 Z', - fill: '#42a5f5', - stroke: '#42a5f5' - } - } - } - }); - - // Store relationship metadata on the link - if (relationship) { - link.set('relationshipName', relationship.LookupDisplayName); - link.set('relationshipSchema', relationship.RelationshipSchema); - link.set('sourceEntity', entity.SchemaName); - link.set('targetEntity', target.Name); - } - - link.addTo(this.graph); - } - } - } - - highlightSelectedKey(graph: dia.Graph, entities: EntityType[], selectedKey: string): void { - const entity = entities.find(e => - e.Attributes.some(a => a.SchemaName === selectedKey && a.IsPrimaryId) - ); - if (!entity) return; - - const entityId = graph.getElements().find(el => - el.get('type') === 'delegate.entity' && - el.get('data')?.entity?.SchemaName === entity.SchemaName - )?.id; - - if (!entityId) return; - - graph.getLinks().forEach(link => { - const target = link.target(); - if (target.id === entityId) { - link.attr('line/stroke', '#ff6b6b'); - link.attr('line/strokeWidth', 4); - } - }); - } - - updateEntityAttributes(): void { - // Simple entities don't display key attributes, so nothing to do - } - - onLinkClick(linkView: dia.LinkView, evt: dia.Event): void { - evt.stopPropagation(); - - const link = linkView.model as dia.Link; - if (this.onLinkClickHandler) { - this.onLinkClickHandler(link); - } else { - // Fallback alert if no handler is provided - alert('Relationship info (simple view)'); - } - } - - getVisibleAttributes(entity: EntityType): AttributeType[] { - // For simple entities, use the visibleAttributeSchemaNames to determine which attributes are "visible" - // If no visibleAttributeSchemaNames is set, only show primary key attributes by default - const visibleSchemaNames = entity.visibleAttributeSchemaNames || - entity.Attributes.filter(attr => attr.IsPrimaryId).map(attr => attr.SchemaName); - - return entity.Attributes.filter(attr => - visibleSchemaNames.includes(attr.SchemaName) - ); - } -} diff --git a/Website/components/diagramview/shared/DiagramConstants.ts b/Website/components/diagramview/shared/DiagramConstants.ts deleted file mode 100644 index a917045..0000000 --- a/Website/components/diagramview/shared/DiagramConstants.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Shared color and style constants for diagram elements - -export const PRESET_COLORS = { - fills: [ - { name: 'Light Green', value: '#dcfce7' }, - { name: 'Light Blue', value: '#dbeafe' }, - { name: 'Light Yellow', value: '#fefce8' }, - { name: 'Light Red', value: '#fee2e2' }, - { name: 'Light Purple', value: '#f3e8ff' }, - ], - borders: [ - { name: 'Green', value: '#22c55e' }, - { name: 'Blue', value: '#3b82f6' }, - { name: 'Yellow', value: '#eab308' }, - { name: 'Red', value: '#ef4444' }, - { name: 'Purple', value: '#a855f7' }, - ] -}; - -export const LINE_STYLES = [ - { name: 'Solid', value: 'none' }, - { name: 'Dashed', value: '5,5' }, - { name: 'Dotted', value: '2,2' } -]; - -export const STROKE_WIDTHS = [ - { name: 'Thin', value: 1 }, - { name: 'Normal', value: 2 }, - { name: 'Thick', value: 3 } -]; diff --git a/Website/components/shared/Layout.tsx b/Website/components/shared/Layout.tsx index a7b2f75..e9e1620 100644 --- a/Website/components/shared/Layout.tsx +++ b/Website/components/shared/Layout.tsx @@ -12,9 +12,10 @@ interface LayoutProps { children: ReactNode; className?: string; showSidebarContent?: boolean; + ignoreMargins?: boolean; } -const Layout = ({ children }: LayoutProps) => { +const Layout = ({ children, ignoreMargins = false }: LayoutProps) => { const { isOpen: sidebarOpen, close } = useSidebar(); const isMobile = useIsMobile(); const { isAuthenticated } = useAuth(); @@ -50,7 +51,7 @@ const Layout = ({ children }: LayoutProps) => {
- + {children} diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 1b0d4e0..eef2464 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -1,33 +1,60 @@ -import React, { createContext, useContext, ReactNode } from 'react'; -import { useDiagram, DiagramState, DiagramActions } from '@/hooks/useDiagram'; +import React, { createContext, useContext, ReactNode, useReducer } from 'react'; -interface DiagramViewContextType extends DiagramState, DiagramActions {} - -const DiagramViewContext = createContext(null); +interface DiagramActions { + setZoom: (zoom: number) => void; + setIsPanning: (isPanning: boolean) => void; +} -interface DiagramViewProviderProps { - children: ReactNode; +export interface DiagramState extends DiagramActions { + zoom: number; + isPanning: boolean; } -export const DiagramViewProvider: React.FC = ({ children }) => { - const diagramViewState = useDiagram(); +const initialState: DiagramState = { + zoom: 1, + isPanning: false, + + setZoom: () => { throw new Error("setZoom not initialized yet!"); }, + setIsPanning: () => { throw new Error("setIsPanning not initialized yet!"); }, +} - return ( - - {children} - - ); -}; +type DiagramViewAction = + | { type: 'SET_ZOOM', payload: number } + | { type: 'SET_IS_PANNING', payload: boolean }; -export const useDiagramViewContext = (): DiagramViewContextType => { - const context = useContext(DiagramViewContext); - if (!context) { - throw new Error('useDiagramViewContext must be used within a DiagramViewProvider'); +const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): DiagramState => { + switch (action.type) { + case 'SET_ZOOM': + return { ...state, zoom: action.payload } + case 'SET_IS_PANNING': + return { ...state, isPanning: action.payload } + default: + return state; } - return context; -}; +} + +const DiagramViewContext = createContext(initialState); +const DiagramViewDispatcher = createContext>(() => { }); + +export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { + const [diagramViewState, dispatch] = useReducer(diagramViewReducer, initialState); + + const setZoom = (zoom: number) => { + dispatch({ type: 'SET_ZOOM', payload: zoom }); + } + + const setIsPanning = (isPanning: boolean) => { + dispatch({ type: 'SET_IS_PANNING', payload: isPanning }); + } + + return ( + + + {children} + + + ) +} -export const useDiagramViewContextSafe = (): DiagramViewContextType | null => { - const context = useContext(DiagramViewContext); - return context; -}; \ No newline at end of file +export const useDiagramView = () => useContext(DiagramViewContext); +export const useDiagramViewDispatch = () => useContext(DiagramViewDispatcher); \ No newline at end of file diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts deleted file mode 100644 index 5ec1ca5..0000000 --- a/Website/hooks/useDiagram.ts +++ /dev/null @@ -1,1169 +0,0 @@ -import { useRef, useState, useCallback, useEffect } from 'react'; -import { dia, routers, shapes } from '@joint/core'; -import { GroupType, EntityType, AttributeType } from '@/lib/Types'; -import { SquareElement } from '@/components/diagramview/elements/SquareElement'; -import { SquareElementView } from '@/components/diagramview/elements/SquareElementView'; -import { TextElement } from '@/components/diagramview/elements/TextElement'; -import { AvoidRouter } from '@/components/diagramview/avoid-router/avoidrouter'; -import { DiagramRenderer } from '@/components/diagramview/DiagramRenderer'; -import { PRESET_COLORS } from '@/components/diagramview/shared/DiagramConstants'; -import { entityStyleManager } from '@/lib/entity-styling'; - -export type DiagramType = 'simple' | 'detailed'; - -export interface DiagramState { - zoom: number; - isPanning: boolean; - selectedElements: string[]; - paper: dia.Paper | null; - graph: dia.Graph | null; - selectedGroup: GroupType | null; - currentEntities: EntityType[]; - mousePosition: { x: number; y: number } | null; - panPosition: { x: number; y: number }; - diagramType: DiagramType; -} - -export interface DiagramActions { - // Zoom - zoomIn: () => void; - zoomOut: () => void; - resetView: () => void; - fitToScreen: () => void; - setZoom: (zoom: number) => void; - - // Pan - setIsPanning: (isPanning: boolean) => void; - - // Select - selectElement: (elementId: string) => void; - selectMultipleElements: (elementIds: string[]) => void; - toggleElementSelection: (elementId: string) => void; - clearSelection: () => void; - - // Other - initializePaper: (container: HTMLElement, options?: any) => void; - destroyPaper: () => void; - selectGroup: (group: GroupType) => void; - updateMousePosition: (position: { x: number; y: number } | null) => void; - updatePanPosition: (position: { x: number; y: number }) => void; - addAttributeToEntity: (entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => void; - removeAttributeFromEntity: (entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => void; - updateDiagramType: (type: DiagramType) => void; - addEntityToDiagram: (entity: EntityType, selectedAttributes?: string[]) => void; - addGroupToDiagram: (group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => void; - removeEntityFromDiagram: (entitySchemaName: string) => void; - addSquareToDiagram: () => void; - addTextToDiagram: () => void; - saveDiagram: () => void; - loadDiagram: (file: File) => Promise; - clearDiagram: () => void; -} - -export const useDiagram = (): DiagramState & DiagramActions => { - const paperRef = useRef(null); - const graphRef = useRef(null); - const zoomRef = useRef(1); - const isPanningRef = useRef(false); - const selectedElementsRef = useRef([]); - const cleanupRef = useRef<(() => void) | null>(null); - const isAddingAttributeRef = useRef(false); - - const [zoom, setZoomState] = useState(1); - const [isPanning, setIsPanningState] = useState(false); - const [selectedElements, setSelectedElements] = useState([]); - const [selectedGroup, setSelectedGroup] = useState(null); - const [currentEntities, setCurrentEntities] = useState([]); - const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null); - const [panPosition, setPanPosition] = useState({ x: 0, y: 0 }); - const [diagramType, setDiagramType] = useState('simple'); - - // State variables to track initialization status for React dependencies - const [paperInitialized, setPaperInitialized] = useState(false); - const [graphInitialized, setGraphInitialized] = useState(false); - - // Update state when refs change (for UI updates) - const updateZoomDisplay = useCallback((newZoom: number) => { - zoomRef.current = newZoom; - setZoomState(newZoom); - }, []); - - const updatePanningDisplay = useCallback((newPanning: boolean) => { - isPanningRef.current = newPanning; - setIsPanningState(newPanning); - }, []); - - const zoomIn = useCallback(() => { - if (paperRef.current) { - const currentScale = paperRef.current.scale(); - const newScale = Math.min(currentScale.sx * 1.2, 3); - paperRef.current.scale(newScale, newScale); - updateZoomDisplay(newScale); - } - }, [updateZoomDisplay]); - - const zoomOut = useCallback(() => { - if (paperRef.current) { - const currentScale = paperRef.current.scale(); - const newScale = Math.max(currentScale.sx / 1.2, 0.1); - paperRef.current.scale(newScale, newScale); - updateZoomDisplay(newScale); - } - }, [updateZoomDisplay]); - - const resetView = useCallback(() => { - if (paperRef.current) { - paperRef.current.scale(1, 1); - paperRef.current.translate(0, 0); - updateZoomDisplay(1); - setPanPosition({ x: 0, y: 0 }); - clearSelection(); - } - }, [updateZoomDisplay]); - - const fitToScreen = useCallback(() => { - if (paperRef.current && graphRef.current) { - const elements = graphRef.current.getElements(); - if (elements.length > 0) { - const bbox = graphRef.current.getBBox(); - if (bbox) { - const paperSize = paperRef.current.getComputedSize(); - const scaleX = (paperSize.width - 100) / bbox.width; - const scaleY = (paperSize.height - 100) / bbox.height; - const scale = Math.min(scaleX, scaleY, 2); - paperRef.current.scale(scale, scale); - - // Center the content manually - const centerX = (paperSize.width - bbox.width * scale) / 2 - bbox.x * scale; - const centerY = (paperSize.height - bbox.height * scale) / 2 - bbox.y * scale; - paperRef.current.translate(centerX, centerY); - - updateZoomDisplay(scale); - setPanPosition({ x: centerX, y: centerY }); - } - } - } - }, [updateZoomDisplay]); - - const setZoom = useCallback((newZoom: number) => { - if (paperRef.current) { - paperRef.current.scale(newZoom, newZoom); - updateZoomDisplay(newZoom); - } - }, [updateZoomDisplay]); - - const setIsPanning = useCallback((newPanning: boolean) => { - updatePanningDisplay(newPanning); - }, [updatePanningDisplay]); - - const selectElement = useCallback((elementId: string) => { - const newSelection = [elementId]; - selectedElementsRef.current = newSelection; - setSelectedElements(newSelection); - }, []); - - const selectMultipleElements = useCallback((elementIds: string[]) => { - selectedElementsRef.current = elementIds; - setSelectedElements(elementIds); - }, []); - - const toggleElementSelection = useCallback((elementId: string) => { - setSelectedElements(prev => { - const newSelection = prev.includes(elementId) - ? prev.filter(id => id !== elementId) - : [...prev, elementId]; - selectedElementsRef.current = newSelection; - return newSelection; - }); - }, []); - - const clearSelection = useCallback(() => { - selectedElementsRef.current = []; - setSelectedElements([]); - }, []); - - const selectGroup = useCallback((group: GroupType) => { - setSelectedGroup(group); - - // Initialize entities with default visible attributes - const entitiesWithVisibleAttributes = group.Entities.map(entity => { - // Get primary key - const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); - - // Get custom lookup attributes (initially visible) - const customLookupAttributes = entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute - ); - - // Create initial visible attributes list - const initialVisibleAttributes = [ - ...(primaryKey ? [primaryKey.SchemaName] : []), - ...customLookupAttributes.map(attr => attr.SchemaName) - ]; - - return { - ...entity, - visibleAttributeSchemaNames: initialVisibleAttributes - }; - }); - - setCurrentEntities(entitiesWithVisibleAttributes); - clearSelection(); - }, [clearSelection]); - - const updateMousePosition = useCallback((position: { x: number; y: number } | null) => { - setMousePosition(position); - }, []); - - const updatePanPosition = useCallback((position: { x: number; y: number }) => { - setPanPosition(position); - }, []); - - const addAttributeToEntity = useCallback((entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => { - // Prevent double additions - if (isAddingAttributeRef.current) { - return; - } - - isAddingAttributeRef.current = true; - - if (!graphRef.current) { - isAddingAttributeRef.current = false; - return; - } - - // Update the currentEntities state first - setCurrentEntities(prev => { - const updated = prev.map(entity => { - if (entity.SchemaName === entitySchemaName) { - // Check if attribute already exists in the entity - const attributeExists = entity.Attributes.some((attr: AttributeType) => - attr.SchemaName === attribute.SchemaName - ); - - // Get current visible attributes list - const currentVisibleAttributes = (entity.visibleAttributeSchemaNames || []); - - if (attributeExists) { - // Attribute already exists, just add it to visible list if not already there - return { - ...entity, - visibleAttributeSchemaNames: currentVisibleAttributes.includes(attribute.SchemaName) - ? currentVisibleAttributes - : [...currentVisibleAttributes, attribute.SchemaName] - }; - } else { - // Attribute doesn't exist, add it to entity and make it visible - return { - ...entity, - Attributes: [...entity.Attributes, attribute], - visibleAttributeSchemaNames: [...currentVisibleAttributes, attribute.SchemaName] - }; - } - } - return entity; - }); - - // Update the diagram using the renderer's unified method - if (renderer) { - const updatedEntity = updated.find(e => e.SchemaName === entitySchemaName); - if (updatedEntity) { - renderer.updateEntity(entitySchemaName, updatedEntity); - } - } - - return updated; - }); - - // Reset the flag - isAddingAttributeRef.current = false; - }, []); - - const removeAttributeFromEntity = useCallback((entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => { - if (!graphRef.current) { - return; - } - - // Update the currentEntities state first - setCurrentEntities(prev => { - const updated = prev.map(entity => { - if (entity.SchemaName === entitySchemaName) { - // Remove from visible attributes list - const updatedVisibleAttributes = (entity.visibleAttributeSchemaNames || []) - .filter((attrName: string) => attrName !== attribute.SchemaName); - - return { - ...entity, - visibleAttributeSchemaNames: updatedVisibleAttributes - }; - } - return entity; - }); - - // Update the diagram using the renderer's unified method - if (renderer) { - const updatedEntity = updated.find(e => e.SchemaName === entitySchemaName); - if (updatedEntity) { - renderer.updateEntity(entitySchemaName, updatedEntity); - } - } - - return updated; - }); - }, []); - - const updateDiagramType = useCallback((type: DiagramType) => { - setDiagramType(type); - }, []); - - const addEntityToDiagram = useCallback((entity: EntityType, selectedAttributes?: string[]) => { - if (!graphRef.current || !paperRef.current) { - return; - } - - // Check if entity already exists in the diagram - const existingEntity = currentEntities.find(e => e.SchemaName === entity.SchemaName); - if (existingEntity) { - return; // Entity already in diagram - } - - let initialVisibleAttributes: string[]; - - if (selectedAttributes) { - // Use provided selected attributes - initialVisibleAttributes = selectedAttributes; - } else { - // Initialize entity with default visible attributes - const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); - const customLookupAttributes = entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute - ); - - initialVisibleAttributes = [ - ...(primaryKey ? [primaryKey.SchemaName] : []), - ...customLookupAttributes.map(attr => attr.SchemaName) - ]; - } - - const entityWithVisibleAttributes = { - ...entity, - visibleAttributeSchemaNames: initialVisibleAttributes - }; - - // Update current entities - const updatedEntities = [...currentEntities, entityWithVisibleAttributes]; - setCurrentEntities(updatedEntities); - }, [currentEntities, diagramType, fitToScreen]); - - const addGroupToDiagram = useCallback((group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => { - if (!graphRef.current || !paperRef.current) { - return; - } - - // Filter out entities that are already in the diagram - const newEntities = group.Entities.filter(entity => - !currentEntities.some(e => e.SchemaName === entity.SchemaName) - ); - - if (newEntities.length === 0) { - return; // All entities from this group are already in diagram - } - - // Initialize new entities with provided or default visible attributes - const entitiesWithVisibleAttributes = newEntities.map(entity => { - let initialVisibleAttributes: string[]; - - if (selectedAttributes && selectedAttributes[entity.SchemaName]) { - // Use the provided selected attributes - initialVisibleAttributes = selectedAttributes[entity.SchemaName]; - } else { - // Fall back to default (primary key + custom lookup attributes) - const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); - const customLookupAttributes = entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute - ); - - initialVisibleAttributes = [ - ...(primaryKey ? [primaryKey.SchemaName] : []), - ...customLookupAttributes.map(attr => attr.SchemaName) - ]; - } - - return { - ...entity, - visibleAttributeSchemaNames: initialVisibleAttributes - }; - }); - - // Update current entities with new entities from the group - const updatedEntities = [...currentEntities, ...entitiesWithVisibleAttributes]; - setCurrentEntities(updatedEntities); - }, [currentEntities]); - - const removeEntityFromDiagram = useCallback((entitySchemaName: string) => { - if (!graphRef.current) { - return; - } - - // Remove the entity from currentEntities state - const updatedEntities = currentEntities.filter(entity => entity.SchemaName !== entitySchemaName); - setCurrentEntities(updatedEntities); - - // Find and remove the entity element from the graph - const entityElement = graphRef.current.getElements().find(el => - el.get('type') === 'delegate.entity' && - el.get('data')?.entity?.SchemaName === entitySchemaName - ); - - if (entityElement) { - // Remove all links connected to this entity - const connectedLinks = graphRef.current.getConnectedLinks(entityElement); - connectedLinks.forEach(link => link.remove()); - - // Remove the entity element - entityElement.remove(); - } - }, [currentEntities, fitToScreen]); - - const addSquareToDiagram = useCallback(() => { - if (!graphRef.current || !paperRef.current) { - return; - } - - // Get all existing elements to find the lowest Y position (bottom-most) - const allElements = graphRef.current.getElements(); - let lowestY = 50; // Default starting position - - if (allElements.length > 0) { - // Find the bottom-most element and add margin - allElements.forEach(element => { - const bbox = element.getBBox(); - const elementBottom = bbox.y + bbox.height; - if (elementBottom > lowestY) { - lowestY = elementBottom + 30; // Add 30px margin - } - }); - } - - // Create a new square element - const squareElement = new SquareElement({ - position: { - x: 100, // Fixed X position - y: lowestY - }, - data: { - id: `square-${Date.now()}`, // Unique ID - borderColor: PRESET_COLORS.borders[0].value, - fillColor: PRESET_COLORS.fills[0].value, - borderWidth: 2, - borderType: 'dashed', - opacity: 0.7 - } - }); - - // Add the square to the graph - squareElement.addTo(graphRef.current); - - // Send square to the back so it renders behind entities - squareElement.toBack(); - - return squareElement; - }, []); - - const addTextToDiagram = useCallback(() => { - if (!graphRef.current || !paperRef.current) { - return; - } - - // Get all existing elements to find the lowest Y position (bottom-most) - const allElements = graphRef.current.getElements(); - let lowestY = 50; // Default starting position - - if (allElements.length > 0) { - // Find the bottom-most element and add margin - allElements.forEach(element => { - const bbox = element.getBBox(); - const elementBottom = bbox.y + bbox.height; - if (elementBottom > lowestY) { - lowestY = elementBottom + 30; // Add 30px margin - } - }); - } - - // Create a new text element - const textElement = new TextElement({ - position: { - x: 100, // Fixed X position - y: lowestY - }, - size: { width: 120, height: 25 }, - attrs: { - body: { - fill: 'transparent', - stroke: 'none' - }, - label: { - text: 'Sample Text', - fill: 'black', - fontSize: 14, - fontFamily: 'Roboto, sans-serif', - textAnchor: 'start', - textVerticalAnchor: 'top', - x: 2, - y: 2 - } - } - }); - - // Don't call updateTextElement in constructor to avoid positioning conflicts - textElement.set('data', { - text: 'Text Element', - fontSize: 14, - fontFamily: 'Roboto, sans-serif', - color: 'black', - backgroundColor: 'transparent', - padding: 8, - borderRadius: 4, - textAlign: 'left' - }); - - // Add the text to the graph - textElement.addTo(graphRef.current); - - return textElement; - }, []); - - const saveDiagram = useCallback(() => { - if (!graphRef.current) { - console.warn('No graph available to save'); - return; - } - - // Use JointJS built-in JSON export - const graphJSON = graphRef.current.toJSON(); - - // Create diagram data structure with additional metadata - const diagramData = { - version: '1.0', - timestamp: new Date().toISOString(), - diagramType, - currentEntities, - graph: graphJSON, - viewState: { - panPosition, - zoom - } - }; - - // Create blob and download - const jsonString = JSON.stringify(diagramData, null, 2); - const blob = new Blob([jsonString], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - - // Create download link - const link = document.createElement('a'); - link.href = url; - link.download = `diagram-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }, [graphRef, diagramType, currentEntities, panPosition, zoom]); - - const loadDiagram = useCallback(async (file: File): Promise => { - try { - const text = await file.text(); - const diagramData = JSON.parse(text); - - if (!graphRef.current || !paperRef.current) { - console.warn('Graph or paper not available for loading'); - return; - } - - // Clear current diagram - graphRef.current.clear(); - - // Use JointJS built-in JSON import - if (diagramData.graph) { - // Manual recreation approach since cellNamespace isn't working - const cells = diagramData.graph.cells || []; - - cells.forEach((cellData: any) => { - let cell; - - if (cellData.type === 'delegate.square') { - cell = new SquareElement({ - id: cellData.id, - position: cellData.position, - size: cellData.size, - attrs: cellData.attrs, - data: cellData.data - }); - } else if (cellData.type === 'delegate.text') { - cell = new TextElement({ - id: cellData.id, - position: cellData.position, - size: cellData.size, - attrs: cellData.attrs, - data: cellData.data - }); - } else { - try { - // Create a temporary graph to deserialize the cell - const tempGraph = new dia.Graph(); - tempGraph.fromJSON({ cells: [cellData] }); - cell = tempGraph.getCells()[0]; - } catch (error) { - console.warn('Failed to create cell:', cellData.type, error); - return; - } - } - - if (cell) { - graphRef.current!.addCell(cell); - } - }); - - } else { - console.warn('No graph data found in diagram file'); - } - - // Restore diagram type - if (diagramData.diagramType) { - setDiagramType(diagramData.diagramType); - } - - // Restore entities - if (diagramData.currentEntities) { - setCurrentEntities(diagramData.currentEntities); - } - - // Restore view settings - if (diagramData.viewState) { - const { panPosition: savedPanPosition, zoom: savedZoom } = diagramData.viewState; - - if (savedZoom && paperRef.current) { - paperRef.current.scale(savedZoom, savedZoom); - updateZoomDisplay(savedZoom); - } - - if (savedPanPosition && paperRef.current) { - paperRef.current.translate(savedPanPosition.x, savedPanPosition.y); - setPanPosition(savedPanPosition); - } - } - } catch (error) { - console.error('Failed to load diagram:', error); - console.error('Error stack:', error instanceof Error ? error.stack : 'No stack trace'); - throw new Error('Failed to load diagram file. Please check the file format.'); - } - }, [graphRef, paperRef, updateZoomDisplay]); - - const clearDiagram = useCallback(() => { - if (!graphRef.current) { - console.warn('Graph not available for clearing'); - return; - } - - // Clear the entire diagram - graphRef.current.clear(); - - // Reset currentEntities state - setCurrentEntities([]); - - // Clear selection - clearSelection(); - - }, [graphRef, clearSelection, setCurrentEntities]); - - const initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { - // Create graph if it doesn't exist - if (!graphRef.current) { - graphRef.current = new dia.Graph(); - setGraphInitialized(true); - } - - try { - await AvoidRouter.load(); - } catch (error) { - console.error('❌ Failed to initialize AvoidRouter:', error); - // Continue without avoid router if it fails - } - - let avoidRouter; - try { - avoidRouter = new AvoidRouter(graphRef.current, { - shapeBufferDistance: 10, - idealNudgingDistance: 15, - }); - avoidRouter.routeAll(); - avoidRouter.addGraphListeners(); - (routers as any).avoid = function(vertices: any, options: any, linkView: any) { - const graph = linkView.model.graph as dia.Graph; - const avoidRouterInstance = (graph as any).__avoidRouter__ as AvoidRouter; - - if (!avoidRouterInstance) { - console.warn('AvoidRouter not initialized on graph.'); - return null; - } - - const link = linkView.model as dia.Link; - - // This will update link using libavoid if possible - avoidRouterInstance.updateConnector(link); - const connRef = avoidRouterInstance.edgeRefs[link.id]; - if (!connRef) return null; - - const route = connRef.displayRoute(); - return avoidRouterInstance.getVerticesFromAvoidRoute(route); - }; - (graphRef.current as any).__avoidRouter__ = avoidRouter; - } catch (error) { - console.error('Failed to initialize AvoidRouter instance:', error); - // Continue without avoid router functionality - } - - // Create paper with light amber background - const paper = new dia.Paper({ - el: container, - model: graphRef.current, - width: '100%', - height: '100%', - gridSize: 8, - background: { - color: '#fef3c7', // Light amber background - ...options.background - }, - // Configure custom views - cellViewNamespace: { - 'delegate': { - 'square': SquareElementView - } - }, - // Disable interactive for squares when resize handles are visible - interactive: function(cellView: any) { - const element = cellView.model; - if (element.get('type') === 'delegate.square') { - const data = element.get('data') || {}; - // Disable dragging if resize handles are visible - if (data.isSelected) { - return false; - } - } - return true; // Enable dragging for other elements or unselected squares - }, - ...options - }); - - paperRef.current = paper; - setPaperInitialized(true); - - // Update entity style manager when selected elements change - const updateEntityStyleManager = () => { - entityStyleManager.handleSelectionChange( - selectedElementsRef.current, - graphRef.current!, - paper - ); - }; - - // Area selection state tracking - let isSelecting = false; - let selectionStartX = 0; - let selectionStartY = 0; - let selectionElement: SVGRectElement | null = null; - let currentAreaSelection: string[] = []; // Track current area selection - - // Create selection overlay element - const createSelectionOverlay = (x: number, y: number, width: number, height: number) => { - const paperSvg = paper.el.querySelector('svg'); - if (!paperSvg) return null; - - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - rect.setAttribute('x', x.toString()); - rect.setAttribute('y', y.toString()); - rect.setAttribute('width', width.toString()); - rect.setAttribute('height', height.toString()); - rect.setAttribute('fill', 'rgba(59, 130, 246, 0.1)'); - rect.setAttribute('stroke', '#3b82f6'); - rect.setAttribute('stroke-width', '2'); - rect.setAttribute('stroke-dasharray', '5,5'); - rect.setAttribute('pointer-events', 'none'); - rect.style.zIndex = '1000'; - - paperSvg.appendChild(rect); - return rect; - }; - - // Setup event listeners - paper.on('blank:pointerdown', (evt: any) => { - const isCtrlPressed = evt.originalEvent?.ctrlKey || evt.originalEvent?.metaKey; - - if (isCtrlPressed) { - // Ctrl + drag = pan - updatePanningDisplay(true); - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'grabbing'; - } else { - // Regular drag = area selection - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - - // Calculate start position in diagram coordinates - selectionStartX = (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - selectionStartY = (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - isSelecting = true; - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'crosshair'; - } - }); - - paper.on('blank:pointerup', () => { - if (isPanningRef.current) { - updatePanningDisplay(false); - } - - if (isSelecting) { - // Finalize selection and apply permanent visual feedback - updateEntityStyleManager(); - - isSelecting = false; - currentAreaSelection = []; // Clear the area selection tracking - // Remove selection overlay - if (selectionElement) { - selectionElement.remove(); - selectionElement = null; - } - } - - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'default'; - }); - - paper.on('blank:pointermove', (evt: any) => { - if (isPanningRef.current) { - // Handle panning - const currentTranslate = paper.translate(); - const deltaX = evt.originalEvent.movementX || 0; - const deltaY = evt.originalEvent.movementY || 0; - const newTranslateX = currentTranslate.tx + deltaX; - const newTranslateY = currentTranslate.ty + deltaY; - paper.translate(newTranslateX, newTranslateY); - updatePanPosition({ x: newTranslateX, y: newTranslateY }); - } else if (isSelecting) { - // Handle area selection - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - - // Calculate current mouse position in diagram coordinates - const currentX = (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - const currentY = (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - // Calculate selection rectangle bounds - const x = Math.min(selectionStartX, currentX); - const y = Math.min(selectionStartY, currentY); - const width = Math.abs(currentX - selectionStartX); - const height = Math.abs(currentY - selectionStartY); - - // Convert to screen coordinates for overlay - const screenX = x * currentScale.sx + currentTranslate.tx; - const screenY = y * currentScale.sy + currentTranslate.ty; - const screenWidth = width * currentScale.sx; - const screenHeight = height * currentScale.sy; - - // Update or create selection overlay - if (selectionElement) { - selectionElement.setAttribute('x', screenX.toString()); - selectionElement.setAttribute('y', screenY.toString()); - selectionElement.setAttribute('width', screenWidth.toString()); - selectionElement.setAttribute('height', screenHeight.toString()); - } else { - selectionElement = createSelectionOverlay(screenX, screenY, screenWidth, screenHeight); - } - - // Select elements within the area and provide visual feedback - if (graphRef.current && width > 10 && height > 10) { // Minimum selection size - const elementsInArea = graphRef.current.getElements().filter(element => { - const bbox = element.getBBox(); - const elementType = element.get('type'); - - // Check if element center is within selection bounds - const elementCenterX = bbox.x + bbox.width / 2; - const elementCenterY = bbox.y + bbox.height / 2; - - return elementCenterX >= x && elementCenterX <= x + width && - elementCenterY >= y && elementCenterY <= y + height; - }); - - // Update selected elements in real-time during drag - const selectedIds = elementsInArea.map(el => el.id.toString()); - currentAreaSelection = selectedIds; // Store for use in pointerup - selectedElementsRef.current = selectedIds; - setSelectedElements(selectedIds); - - // Apply visual feedback using entity style manager - entityStyleManager.handleSelectionChange(selectedIds, graphRef.current, paper); - } - } - }); - - // Group dragging state - let isGroupDragging = false; - let groupDragStartPositions: { [id: string]: { x: number; y: number } } = {}; - let dragStartMousePos = { x: 0, y: 0 }; - - // Element interaction handlers - paper.on('element:pointerdown', (elementView: dia.ElementView, evt: any) => { - const element = elementView.model; - const elementType = element.get('type'); - - const elementId = element.id.toString(); - const isCtrlPressed = evt.originalEvent?.ctrlKey || evt.originalEvent?.metaKey; - const currentSelection = selectedElementsRef.current; - - if (isCtrlPressed) { - // Ctrl+click: toggle selection - toggleElementSelection(elementId); - evt.preventDefault(); - evt.stopPropagation(); - - // Update visual feedback after a short delay to let state update - setTimeout(() => { - updateEntityStyleManager(); - }, 0); - } else if (currentSelection.includes(elementId) && currentSelection.length > 1) { - // Start group dragging if clicking on already selected element (and there are multiple selected) - isGroupDragging = true; - groupDragStartPositions = {}; - - // Store initial positions for all selected elements - currentSelection.forEach(id => { - const elem = graphRef.current?.getCell(id); - if (elem) { - const pos = elem.position(); - groupDragStartPositions[id] = { x: pos.x, y: pos.y }; - } - }); - - // Store initial mouse position - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - dragStartMousePos = { - x: (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx, - y: (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy - }; - - evt.preventDefault(); - evt.stopPropagation(); - } else if (currentSelection.includes(elementId) && currentSelection.length === 1) { - // Single selected element - allow normal JointJS dragging behavior - // Don't prevent default, let JointJS handle the dragging - return; - } else { - // Regular click: clear selection and select only this element - clearSelection(); - selectElement(elementId); - - // Update visual feedback - updateEntityStyleManager(); - } - }); - - paper.on('element:pointermove', (elementView: dia.ElementView, evt: any) => { - if (isGroupDragging && Object.keys(groupDragStartPositions).length > 0) { - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - - const currentMouseX = (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - const currentMouseY = (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - const deltaX = currentMouseX - dragStartMousePos.x; - const deltaY = currentMouseY - dragStartMousePos.y; - - // Move all selected elements - Object.keys(groupDragStartPositions).forEach(id => { - const elem = graphRef.current?.getCell(id); - if (elem) { - const startPos = groupDragStartPositions[id]; - elem.set('position', { x: startPos.x + deltaX, y: startPos.y + deltaY }); - } - }); - - evt.preventDefault(); - evt.stopPropagation(); - } - }); - - paper.on('element:pointerup', () => { - isGroupDragging = false; - groupDragStartPositions = {}; - }); - - // Clear selection when clicking on blank area (unless Ctrl+dragging) - paper.on('blank:pointerdown', (evt: any) => { - const isCtrlPressed = evt.originalEvent?.ctrlKey || evt.originalEvent?.metaKey; - - if (isCtrlPressed) { - // Ctrl + drag = pan - updatePanningDisplay(true); - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'grabbing'; - } else { - // Clear selection before starting area selection - if (!isSelecting) { - clearSelection(); - // Clear visual feedback - updateEntityStyleManager(); - } - - // Regular drag = area selection - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - - // Calculate start position in diagram coordinates - selectionStartX = (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - selectionStartY = (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - isSelecting = true; - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'crosshair'; - } - }); - - // Add mouse move listener for coordinate tracking - const paperEl = paper.el as HTMLElement; - const handleMouseMove = (e: MouseEvent) => { - const rect = paperEl.getBoundingClientRect(); - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - - // Calculate mouse position relative to diagram coordinates - const mouseX = (e.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - const mouseY = (e.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - updateMousePosition({ x: Math.round(mouseX), y: Math.round(mouseY) }); - }; - - const handleMouseLeave = () => { - updateMousePosition(null); - }; - - // Add wheel event listener for zoom - const handleWheel = (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const currentScale = paper.scale(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - const newScale = Math.max(0.1, Math.min(3, currentScale.sx * delta)); - - // Get mouse position relative to paper - const rect = paperEl.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - // Calculate zoom center - const currentTranslate = paper.translate(); - const zoomCenterX = (mouseX - currentTranslate.tx) / currentScale.sx; - const zoomCenterY = (mouseY - currentTranslate.ty) / currentScale.sy; - - // Apply zoom - paper.scale(newScale, newScale); - - // Adjust translation to zoom towards mouse position - const newTranslateX = mouseX - zoomCenterX * newScale; - const newTranslateY = mouseY - zoomCenterY * newScale; - paper.translate(newTranslateX, newTranslateY); - - updateZoomDisplay(newScale); - updatePanPosition({ x: newTranslateX, y: newTranslateY }); - }; - - paperEl.addEventListener('wheel', handleWheel); - paperEl.addEventListener('mousemove', handleMouseMove); - paperEl.addEventListener('mouseleave', handleMouseLeave); - - // Store cleanup function - cleanupRef.current = () => { - paperEl.removeEventListener('wheel', handleWheel); - paperEl.removeEventListener('mousemove', handleMouseMove); - paperEl.removeEventListener('mouseleave', handleMouseLeave); - paper.remove(); - }; - - return paper; - }, [updateZoomDisplay, updatePanningDisplay, updateMousePosition, updatePanPosition, setGraphInitialized, setPaperInitialized]); - - const destroyPaper = useCallback(() => { - if (cleanupRef.current) { - cleanupRef.current(); - cleanupRef.current = null; - } - paperRef.current = null; - graphRef.current = null; - setPaperInitialized(false); - setGraphInitialized(false); - }, [setPaperInitialized, setGraphInitialized]); - - // Update selection styling whenever selectedElements changes - useEffect(() => { - if (paperRef.current && graphRef.current) { - entityStyleManager.handleSelectionChange(selectedElements, graphRef.current, paperRef.current); - } - }, [selectedElements]); - - // Cleanup on unmount - useEffect(() => { - return () => { - destroyPaper(); - }; - }, [destroyPaper]); - - return { - // State - zoom, - isPanning, - selectedElements, - paper: paperInitialized ? paperRef.current : null, - graph: graphInitialized ? graphRef.current : null, - selectedGroup, - currentEntities, - mousePosition, - panPosition, - diagramType, - - // Actions - zoomIn, - zoomOut, - resetView, - fitToScreen, - setZoom, - setIsPanning, - selectElement, - selectMultipleElements, - toggleElementSelection, - clearSelection, - initializePaper, - destroyPaper, - selectGroup, - updateMousePosition, - updatePanPosition, - addAttributeToEntity, - removeAttributeFromEntity, - updateDiagramType, - addEntityToDiagram, - addGroupToDiagram, - removeEntityFromDiagram, - addSquareToDiagram, - addTextToDiagram, - saveDiagram, - loadDiagram, - clearDiagram, - }; -}; \ No newline at end of file From 6f03fb2b38a51a91301e543ca2e35ede67ce525c Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 13 Oct 2025 18:47:01 +0200 Subject: [PATCH 02/51] feat: simple canvas movement. zoom, pan and scroll --- .../diagramview/DiagramContainer.tsx | 35 +++ .../components/diagramview/DiagramView.tsx | 6 +- .../{ => layout}/GridLayoutManager.ts | 0 Website/contexts/DiagramViewContext.tsx | 243 +++++++++++++++++- 4 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 Website/components/diagramview/DiagramContainer.tsx rename Website/components/diagramview/{ => layout}/GridLayoutManager.ts (100%) diff --git a/Website/components/diagramview/DiagramContainer.tsx b/Website/components/diagramview/DiagramContainer.tsx new file mode 100644 index 0000000..649b47d --- /dev/null +++ b/Website/components/diagramview/DiagramContainer.tsx @@ -0,0 +1,35 @@ +'use client'; + +import React, { useEffect, useRef } from "react"; +import { Box } from "@mui/material"; +import { useDiagramView } from "@/contexts/DiagramViewContext"; + +interface IDiagramContainerProps { + +} + +export default function DiagramContainer({ }: IDiagramContainerProps) { + + const { canvas } = useDiagramView(); + + return ( + +
+ + ); +} diff --git a/Website/components/diagramview/DiagramView.tsx b/Website/components/diagramview/DiagramView.tsx index 4aa7f8e..6dec518 100644 --- a/Website/components/diagramview/DiagramView.tsx +++ b/Website/components/diagramview/DiagramView.tsx @@ -3,7 +3,7 @@ import { useSidebar } from "@/contexts/SidebarContext"; import React, { useEffect } from "react"; import { SidebarDiagramView } from "./SidebarDiagramView"; -import { Box } from "@mui/material"; +import DiagramContainer from "./DiagramContainer"; interface IDiagramViewProps { @@ -18,8 +18,6 @@ export default function DiagramView({ }: IDiagramViewProps) { }, []) return ( - - Hello - + ); } \ No newline at end of file diff --git a/Website/components/diagramview/GridLayoutManager.ts b/Website/components/diagramview/layout/GridLayoutManager.ts similarity index 100% rename from Website/components/diagramview/GridLayoutManager.ts rename to Website/components/diagramview/layout/GridLayoutManager.ts diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index eef2464..6af0a74 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -1,26 +1,34 @@ -import React, { createContext, useContext, ReactNode, useReducer } from 'react'; +import { dia, shapes } from '@joint/core'; +import React, { createContext, useContext, ReactNode, useReducer, useEffect, useRef } from 'react'; interface DiagramActions { setZoom: (zoom: number) => void; setIsPanning: (isPanning: boolean) => void; + setTranslate: (translate: { x: number; y: number }) => void; } export interface DiagramState extends DiagramActions { + canvas: React.MutableRefObject; zoom: number; isPanning: boolean; + translate: { x: number; y: number }; } const initialState: DiagramState = { zoom: 1, isPanning: false, - + translate: { x: 0, y: 0 }, + canvas: React.createRef(), + setZoom: () => { throw new Error("setZoom not initialized yet!"); }, setIsPanning: () => { throw new Error("setIsPanning not initialized yet!"); }, + setTranslate: () => { throw new Error("setTranslate not initialized yet!"); }, } type DiagramViewAction = | { type: 'SET_ZOOM', payload: number } - | { type: 'SET_IS_PANNING', payload: boolean }; + | { type: 'SET_IS_PANNING', payload: boolean } + | { type: 'SET_TRANSLATE', payload: { x: number; y: number } }; const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): DiagramState => { switch (action.type) { @@ -28,6 +36,8 @@ const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): Dia return { ...state, zoom: action.payload } case 'SET_IS_PANNING': return { ...state, isPanning: action.payload } + case 'SET_TRANSLATE': + return { ...state, translate: action.payload } default: return state; } @@ -47,8 +57,233 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { dispatch({ type: 'SET_IS_PANNING', payload: isPanning }); } + const setTranslate = (translate: { x: number; y: number }) => { + dispatch({ type: 'SET_TRANSLATE', payload: translate }); + } + + useEffect(() => { + if (!diagramViewState.canvas.current) return; + + const graph = new dia.Graph({}, { cellNamespace: shapes }); + const paper = new dia.Paper({ + model: graph, + width: '100%', + height: '100%', + gridSize: 20, + drawGrid: { + name: 'doubleMesh', + args: [ + { color: '#f0f0f0', thickness: 1 }, // Minor grid lines + { color: '#d0d0d0', thickness: 2, scaleFactor: 5 } // Major grid lines + ] + }, + background: { + color: '#fafafa' + }, + interactive: true, + snapToGrid: true, + frozen: true, + async: true, + cellViewNamespace: shapes + }); + + diagramViewState.canvas.current.appendChild(paper.el); + + // Variables for panning and zooming + let isPanning = false; + let panStartX = 0; + let panStartY = 0; + let currentZoom = diagramViewState.zoom; + let currentTranslate = { ...diagramViewState.translate }; + + // Mouse down handler for panning + const handleMouseDown = (evt: MouseEvent) => { + if (evt.ctrlKey) { + evt.preventDefault(); + isPanning = true; + panStartX = evt.clientX; + panStartY = evt.clientY; + setIsPanning(true); + diagramViewState.canvas.current!.style.cursor = 'grabbing'; + } + }; + + // Mouse move handler for panning + const handleMouseMove = (evt: MouseEvent) => { + if (isPanning && evt.ctrlKey) { + evt.preventDefault(); + const deltaX = evt.clientX - panStartX; + const deltaY = evt.clientY - panStartY; + + // Update current translate position + currentTranslate.x += deltaX; + currentTranslate.y += deltaY; + + // Apply the full transform (scale + translate) + paper.matrix({ + a: currentZoom, + b: 0, + c: 0, + d: currentZoom, + e: currentTranslate.x, + f: currentTranslate.y + }); + + // Update context state + setTranslate({ ...currentTranslate }); + + panStartX = evt.clientX; + panStartY = evt.clientY; + } + }; + + // Mouse up handler for panning + const handleMouseUp = (evt: MouseEvent) => { + if (isPanning) { + evt.preventDefault(); + isPanning = false; + setIsPanning(false); + diagramViewState.canvas.current!.style.cursor = 'default'; + } + }; + + // Wheel handler for zooming and scrolling + const handleWheel = (evt: WheelEvent) => { + if (evt.ctrlKey) { + // Zoom functionality + evt.preventDefault(); + + const zoomFactor = evt.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.max(0.1, Math.min(3, currentZoom * zoomFactor)); + + if (newZoom !== currentZoom) { + // Get mouse position relative to canvas + const rect = diagramViewState.canvas.current!.getBoundingClientRect(); + const mouseX = evt.clientX - rect.left; + const mouseY = evt.clientY - rect.top; + + // Calculate zoom center offset + const zoomRatio = newZoom / currentZoom; + + // Adjust translation to zoom around mouse position + currentTranslate.x = mouseX - (mouseX - currentTranslate.x) * zoomRatio; + currentTranslate.y = mouseY - (mouseY - currentTranslate.y) * zoomRatio; + + currentZoom = newZoom; + + // Apply the full transform (scale + translate) + paper.matrix({ + a: currentZoom, + b: 0, + c: 0, + d: currentZoom, + e: currentTranslate.x, + f: currentTranslate.y + }); + + // Update context state + setZoom(newZoom); + setTranslate({ ...currentTranslate }); + } + } else { + // Scroll functionality + evt.preventDefault(); + + const scrollSpeed = 50; + + // Handle scrolling with priority for horizontal scroll + if (evt.deltaX !== 0) { + // Horizontal scroll wheel (if available) - only move horizontally + currentTranslate.x -= evt.deltaX > 0 ? scrollSpeed : -scrollSpeed; + } else if (evt.shiftKey) { + // Shift + scroll = horizontal scrolling + currentTranslate.x -= evt.deltaY > 0 ? scrollSpeed : -scrollSpeed; + } else { + // Regular scroll = vertical scrolling only + currentTranslate.y -= evt.deltaY > 0 ? scrollSpeed : -scrollSpeed; + } + + // Apply the full transform (scale + translate) + paper.matrix({ + a: currentZoom, + b: 0, + c: 0, + d: currentZoom, + e: currentTranslate.x, + f: currentTranslate.y + }); + + // Update context state + setTranslate({ ...currentTranslate }); + } + }; + + // Add event listeners + const canvas = diagramViewState.canvas.current; + canvas.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + canvas.addEventListener('wheel', handleWheel, { passive: false }); + + // Add some sample elements + const rect = new shapes.standard.Rectangle({ + position: { x: 100, y: 50 }, + size: { width: 120, height: 80 }, + attrs: { + body: { + fill: '#3b82f6', + stroke: '#1e40af', + strokeWidth: 2, + rx: 8, + ry: 8 + }, + label: { + text: 'Sample Entity', + fill: 'white', + fontSize: 14, + fontFamily: 'Arial, sans-serif' + } + } + }); + + const rect2 = new shapes.standard.Rectangle({ + position: { x: 300, y: 200 }, + size: { width: 120, height: 80 }, + attrs: { + body: { + fill: '#10b981', + stroke: '#059669', + strokeWidth: 2, + rx: 8, + ry: 8 + }, + label: { + text: 'Another Entity', + fill: 'white', + fontSize: 14, + fontFamily: 'Arial, sans-serif' + } + } + }); + + graph.addCells([rect, rect2]); + + // Unfreeze and render the paper to make it interactive + paper.render(); + paper.unfreeze(); + + return () => { + // Remove event listeners + canvas.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + canvas.removeEventListener('wheel', handleWheel); + paper.remove(); + }; + }, []); + return ( - + {children} From efcac09a3db12be1dbadc4e64f765f11b0083fe6 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 13 Oct 2025 18:51:40 +0200 Subject: [PATCH 03/51] chore: disabled gutter for layout --- Website/components/shared/Layout.tsx | 13 ++++++-- Website/contexts/DiagramViewContext.tsx | 43 ------------------------- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/Website/components/shared/Layout.tsx b/Website/components/shared/Layout.tsx index e9e1620..e7db248 100644 --- a/Website/components/shared/Layout.tsx +++ b/Website/components/shared/Layout.tsx @@ -51,9 +51,16 @@ const Layout = ({ children, ignoreMargins = false }: LayoutProps) => {
- - {children} - + { + ignoreMargins ? + + {children} + + : + + {children} + + } diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 6af0a74..30f3496 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -224,49 +224,6 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); canvas.addEventListener('wheel', handleWheel, { passive: false }); - - // Add some sample elements - const rect = new shapes.standard.Rectangle({ - position: { x: 100, y: 50 }, - size: { width: 120, height: 80 }, - attrs: { - body: { - fill: '#3b82f6', - stroke: '#1e40af', - strokeWidth: 2, - rx: 8, - ry: 8 - }, - label: { - text: 'Sample Entity', - fill: 'white', - fontSize: 14, - fontFamily: 'Arial, sans-serif' - } - } - }); - - const rect2 = new shapes.standard.Rectangle({ - position: { x: 300, y: 200 }, - size: { width: 120, height: 80 }, - attrs: { - body: { - fill: '#10b981', - stroke: '#059669', - strokeWidth: 2, - rx: 8, - ry: 8 - }, - label: { - text: 'Another Entity', - fill: 'white', - fontSize: 14, - fontFamily: 'Arial, sans-serif' - } - } - }); - - graph.addCells([rect, rect2]); // Unfreeze and render the paper to make it interactive paper.render(); From 1616db17b8aefe34c512c2f46dc0693b27da092e Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 13 Oct 2025 19:35:25 +0200 Subject: [PATCH 04/51] feat: add entity functionality & darktheme pane --- Website/app/diagram/page.tsx | 13 +- .../diagramview/DiagramContainer.tsx | 1 - .../diagramview/SidebarDiagramView.tsx | 70 +++++++- .../diagramview/panes/EntitySelectionPane.tsx | 170 ++++++++++++++++++ Website/contexts/DiagramViewContext.tsx | 82 ++++++++- Website/lib/icons.tsx | 5 + 6 files changed, 327 insertions(+), 14 deletions(-) create mode 100644 Website/components/diagramview/panes/EntitySelectionPane.tsx diff --git a/Website/app/diagram/page.tsx b/Website/app/diagram/page.tsx index d37e14b..6c90bee 100644 --- a/Website/app/diagram/page.tsx +++ b/Website/app/diagram/page.tsx @@ -2,17 +2,20 @@ import DiagramView from "@/components/diagramview/DiagramView"; import Layout from "@/components/shared/Layout"; +import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; import { DiagramViewProvider } from "@/contexts/DiagramViewContext"; import { Suspense } from "react"; export default function Home() { return ( - - - - - + + + + + + + ) } \ No newline at end of file diff --git a/Website/components/diagramview/DiagramContainer.tsx b/Website/components/diagramview/DiagramContainer.tsx index 649b47d..e3d6716 100644 --- a/Website/components/diagramview/DiagramContainer.tsx +++ b/Website/components/diagramview/DiagramContainer.tsx @@ -9,7 +9,6 @@ interface IDiagramContainerProps { } export default function DiagramContainer({ }: IDiagramContainerProps) { - const { canvas } = useDiagramView(); return ( diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index a8ffdcf..84181b5 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -1,14 +1,76 @@ -import { Box } from '@mui/material'; -import React from 'react'; +import { Box, IconButton, Tooltip, Typography, Grid, Divider } from '@mui/material'; +import React, { useState } from 'react'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; +import { AddSquareIcon } from '@/lib/icons'; +import { EntitySelectionPane } from './panes/EntitySelectionPane'; interface ISidebarDiagramViewProps { } +interface DiagramTool { + id: string; + label: string; + icon: React.ReactNode; + action: () => void; +} + export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { + const [entityPaneOpen, setEntityPaneOpen] = useState(false); + + const handleAddEntity = () => { + setEntityPaneOpen(true); + }; + + const handleClosePane = () => { + setEntityPaneOpen(false); + }; + + const diagramTools: DiagramTool[] = [ + { + id: 'add-entity', + label: 'Add Entity', + icon: AddSquareIcon, + action: handleAddEntity + }, + ]; + return ( - - Hello + + + Diagram Tools + + + + + + Elements + + + + {diagramTools.map((tool) => ( + + + + {tool.icon} + + + + ))} + + + ); } \ No newline at end of file diff --git a/Website/components/diagramview/panes/EntitySelectionPane.tsx b/Website/components/diagramview/panes/EntitySelectionPane.tsx new file mode 100644 index 0000000..daf2d6b --- /dev/null +++ b/Website/components/diagramview/panes/EntitySelectionPane.tsx @@ -0,0 +1,170 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Drawer, + Box, + Typography, + List, + ListItem, + ListItemButton, + ListItemText, + Accordion, + AccordionSummary, + AccordionDetails, + IconButton, + Divider +} from '@mui/material'; +import { + Close as CloseIcon, + ExpandMore as ExpandMoreIcon, + FolderOpen as GroupIcon, + TableChart as EntityIcon +} from '@mui/icons-material'; +import { useDatamodelData } from '@/contexts/DatamodelDataContext'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; +import { GroupType, EntityType } from '@/lib/Types'; + +interface EntitySelectionPaneProps { + open: boolean; + onClose: () => void; +} + +export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) => { + const { groups } = useDatamodelData(); + const { addEntity, zoom, translate } = useDiagramView(); + const [expandedGroups, setExpandedGroups] = useState>(new Map()); + + const handleEntitySelect = (entity: EntityType) => { + // Add entity at center of current view + const centerX = (-translate.x / zoom) + (400 / zoom); + const centerY = (-translate.y / zoom) + (300 / zoom); + + // Use DisplayName for the entity label + addEntity({ x: centerX, y: centerY }, entity.DisplayName); + + // Close the pane after selection + onClose(); + }; + + return ( + + + + + Select Entity to Add + + + + + + + + + {groups.length === 0 ? ( + + No entity groups found. Please load datamodel data first. + + ) : ( + + {groups.map((group: GroupType) => ( + { + setExpandedGroups(prev => { + const newMap = new Map(prev); + if (isExpanded) { + newMap.set(group.Name, true); + } else { + newMap.delete(group.Name); + } + return newMap; + }); + }} + sx={{ + boxShadow: 'none', + '&:before': { display: 'none' }, + '& .MuiAccordionSummary-root': { + minHeight: 48, + '&.Mui-expanded': { minHeight: 48 } + } + }} + > + } + sx={{ + '& .MuiAccordionSummary-content': { + alignItems: 'center', + gap: 1 + } + }} + > + + + {group.Name} + + + ({group.Entities.length} entities) + + + + + {group.Entities.map((entity: EntityType) => ( + + handleEntitySelect(entity)} + sx={{ + borderRadius: 1, + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + ))} + + + + ))} + + )} + + + ); +}; \ No newline at end of file diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 30f3496..afa6559 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -5,6 +5,8 @@ interface DiagramActions { setZoom: (zoom: number) => void; setIsPanning: (isPanning: boolean) => void; setTranslate: (translate: { x: number; y: number }) => void; + addEntity: (position?: { x: number; y: number }, label?: string) => void; + getGraph: () => dia.Graph | null; } export interface DiagramState extends DiagramActions { @@ -23,6 +25,8 @@ const initialState: DiagramState = { setZoom: () => { throw new Error("setZoom not initialized yet!"); }, setIsPanning: () => { throw new Error("setIsPanning not initialized yet!"); }, setTranslate: () => { throw new Error("setTranslate not initialized yet!"); }, + addEntity: () => { throw new Error("addEntity not initialized yet!"); }, + getGraph: () => { throw new Error("getGraph not initialized yet!"); }, } type DiagramViewAction = @@ -61,10 +65,21 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { dispatch({ type: 'SET_TRANSLATE', payload: translate }); } + // Refs to store graph and paper instances + const graphRef = useRef(null); + const paperRef = useRef(null); + useEffect(() => { if (!diagramViewState.canvas.current) return; const graph = new dia.Graph({}, { cellNamespace: shapes }); + graphRef.current = graph; + + // Theme-aware colors using MUI CSS variables + const gridMinorColor = "var(--mui-palette-border-main)"; + const gridMajorColor = "var(--mui-palette-border-main)"; + const backgroundColor = 'var(--mui-palette-background-default)'; + const paper = new dia.Paper({ model: graph, width: '100%', @@ -73,12 +88,12 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { drawGrid: { name: 'doubleMesh', args: [ - { color: '#f0f0f0', thickness: 1 }, // Minor grid lines - { color: '#d0d0d0', thickness: 2, scaleFactor: 5 } // Major grid lines + { color: gridMinorColor, thickness: 1 }, // Minor grid lines + { color: gridMajorColor, thickness: 2, scaleFactor: 5 } // Major grid lines ] }, background: { - color: '#fafafa' + color: backgroundColor }, interactive: true, snapToGrid: true, @@ -87,6 +102,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { cellViewNamespace: shapes }); + paperRef.current = paper; diagramViewState.canvas.current.appendChild(paper.el); // Variables for panning and zooming @@ -218,6 +234,8 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { } }; + + // Add event listeners const canvas = diagramViewState.canvas.current; canvas.addEventListener('mousedown', handleMouseDown); @@ -239,8 +257,64 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }; }, []); + // Context functions + const addEntity = (position?: { x: number; y: number }, label?: string) => { + if (graphRef.current && paperRef.current) { + const x = position?.x ?? 100; + const y = position?.y ?? 100; + + // Convert position if it's in screen coordinates + const paperPoint = paperRef.current.clientToLocalPoint({ x, y }); + + // Theme-aware entity colors using MUI CSS variables + const colors = [ + { fill: 'var(--mui-palette-primary-main)', stroke: 'var(--mui-palette-primary-dark)' }, + { fill: 'var(--mui-palette-success-main)', stroke: 'var(--mui-palette-success-dark)' }, + { fill: 'var(--mui-palette-warning-main)', stroke: 'var(--mui-palette-warning-dark)' }, + { fill: 'var(--mui-palette-error-main)', stroke: 'var(--mui-palette-error-dark)' }, + { fill: 'var(--mui-palette-secondary-main)', stroke: 'var(--mui-palette-secondary-dark)' }, + { fill: 'var(--mui-palette-info-main)', stroke: 'var(--mui-palette-info-dark)' }, + ]; + + const colorIndex = graphRef.current.getCells().length % colors.length; + const color = colors[colorIndex]; + const entityLabel = label || `Entity ${graphRef.current.getCells().length + 1}`; + + // Theme-aware text color using MUI variables + const textColor = 'var(--mui-palette-primary-contrastText)'; + + const rect = new shapes.standard.Rectangle({ + position: { x: paperPoint.x - 60, y: paperPoint.y - 40 }, + size: { width: 120, height: 80 }, + attrs: { + body: { + fill: color.fill, + stroke: color.stroke, + strokeWidth: 2, + rx: 8, + ry: 8 + }, + label: { + text: entityLabel, + fill: textColor, + fontSize: 14, + fontFamily: 'Arial, sans-serif' + } + } + }); + + graphRef.current.addCell(rect); + return rect; + } + return null; + }; + + const getGraph = () => { + return graphRef.current; + }; + return ( - + {children} diff --git a/Website/lib/icons.tsx b/Website/lib/icons.tsx index abfc02c..e3583a2 100644 --- a/Website/lib/icons.tsx +++ b/Website/lib/icons.tsx @@ -38,4 +38,9 @@ export const WarningIcon = +; + +export const AddSquareIcon = + + ; \ No newline at end of file From eef0c8cb8af2af86b32e776859cac4914de307b3 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 13 Oct 2025 20:18:19 +0200 Subject: [PATCH 05/51] feat: initial attempt at managed identity setup for save/load of diagram directly to ADO Repository. --- Infrastructure/main.bicep | 30 +- .../azuredevops/ManagedIdentityAuthService.ts | 67 +++ Website/package-lock.json | 470 +++++++++++++++++- Website/package.json | 1 + azure-pipelines-deploy-jobs.yml | 69 ++- azure-pipelines-external.yml | 8 +- 6 files changed, 638 insertions(+), 7 deletions(-) create mode 100644 Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts diff --git a/Infrastructure/main.bicep b/Infrastructure/main.bicep index 3063b2b..a3a63c8 100644 --- a/Infrastructure/main.bicep +++ b/Infrastructure/main.bicep @@ -1,10 +1,19 @@ +@description('The name of the web app') param solutionId string +@description('Website password') @secure() param websitePassword string +@description('Session secret') @secure() param sessionSecret string - +@description('The location for all resources') var location = resourceGroup().location +@description('Azure DevOps Organization URL') +param adoOrganizationUrl string = '' +@description('Azure DevOps Project Name') +param adoProjectName string = '' +@description('Azure DevOps Repository Name') +param adoRepositoryName string = '' @description('Create an App Service Plan') resource appServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { @@ -23,6 +32,9 @@ resource appServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { resource webApp 'Microsoft.Web/sites@2021-02-01' = { name: 'wa-${solutionId}' location: location + identity: { + type: 'SystemAssigned' + } properties: { serverFarmId: appServicePlan.id httpsOnly: true @@ -38,6 +50,18 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { name: 'WebsiteSessionSecret' value: sessionSecret } + { + name: 'ADO_ORGANIZATION_URL' + value: adoOrganizationUrl + } + { + name: 'ADO_PROJECT_NAME' + value: adoProjectName + } + { + name: 'ADO_REPOSITORY_NAME' + value: adoRepositoryName + } { name: 'WEBSITE_NODE_DEFAULT_VERSION' value: '~20' @@ -49,3 +73,7 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { @description('Output the web app name') output webAppName string = webApp.name +@description('Output the managed identity principal ID') +output managedIdentityPrincipalId string = webApp.identity.principalId +output managedIdentityClientId string = webApp.identity.tenantId +output webAppUrl string = 'https://${webApp.properties.defaultHostName}' diff --git a/Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts b/Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts new file mode 100644 index 0000000..065efc7 --- /dev/null +++ b/Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts @@ -0,0 +1,67 @@ +import { DefaultAzureCredential } from '@azure/identity'; + +interface AdoConfig { + organizationUrl: string; + projectName: string; + repositoryName: string; +} + +class ManagedIdentityAuth { + private credential: DefaultAzureCredential; + private tokenCache: { token: string; expires: Date } | null = null; + private config: AdoConfig; + + constructor() { + this.credential = new DefaultAzureCredential(); + this.config = { + organizationUrl: process.env.ADO_ORGANIZATION_URL || '', + projectName: process.env.ADO_PROJECT_NAME || '', + repositoryName: process.env.ADO_REPOSITORY_NAME || '' + }; + } + + async getAccessToken(): Promise { + if (this.tokenCache && this.tokenCache.expires > new Date()) { + return this.tokenCache.token; + } + + try { + const tokenResponse = await this.credential.getToken( + 'https://app.vssps.visualstudio.com/.default' + ); + + if (!tokenResponse) { + throw new Error('Failed to get managed identity token'); + } + + this.tokenCache = { + token: tokenResponse.token, + expires: new Date(tokenResponse.expiresOnTimestamp) + }; + + return tokenResponse.token; + } catch (error) { + console.error('Error getting managed identity token:', error); + throw error; + } + } + + async makeAuthenticatedRequest(url: string, options: RequestInit = {}): Promise { + const token = await this.getAccessToken(); + + return fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + } + + getConfig(): AdoConfig { + return this.config; + } +} + +export const managedAuth = new ManagedIdentityAuth(); \ No newline at end of file diff --git a/Website/package-lock.json b/Website/package-lock.json index cea30e3..8f49ed5 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -8,6 +8,7 @@ "name": "website", "version": "2.1.1", "dependencies": { + "@azure/identity": "^4.13.0", "@joint/core": "^4.1.3", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", @@ -56,6 +57,164 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.1.tgz", + "integrity": "sha512-UVZlVLfLyz6g3Hy7GNDpooMQonUygH7ghdiSASOOHy97fKj/mPLqgDX7aidOijn+sCMU+WU8NjlPlNTgnvbcGA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", + "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.25.0.tgz", + "integrity": "sha512-kbL+Ae7/UC62wSzxirZddYeVnHvvkvAnSZkBqL55X+jaSXTAXfngnNsDM5acEWU0Q/SAv3gEQfxO1igWOn87Pg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.13.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.0.tgz", + "integrity": "sha512-8oF6nj02qX7eE/6+wFT5NluXRHc05AgdCC3fJnkjiJooq8u7BcLmxaYYSwc2AfEkWRMRi6Eyvvbeqk4U4412Ag==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.0.tgz", + "integrity": "sha512-23BXm82Mp5XnRhrcd4mrHa0xuUNRp96ivu3nRatrfdAqjoeWAGyD0eEAafxAOHAEWWmdlyFK4ELFcdziXyw2sA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2633,6 +2792,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.1.tgz", + "integrity": "sha512-SnbaqayTVFEA6/tYumdF0UmybY0KHyKwGPBXnyckFlrrKdhWFrL3a2HIPXHjht5ZOElKGcXfD2D63P36btb+ww==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -2661,6 +2834,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2950,6 +3132,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-0.1.2.tgz", @@ -2958,6 +3146,21 @@ "optional": true, "peer": true }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -3471,6 +3674,34 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3488,6 +3719,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -3593,6 +3836,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -4753,6 +5005,32 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4987,6 +5265,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5048,6 +5341,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5240,6 +5551,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -5353,6 +5679,28 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5368,6 +5716,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5670,12 +6039,54 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -6616,6 +7027,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7111,6 +7540,18 @@ "node": ">=0.10.0" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7156,9 +7597,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/safe-regex-test": { "version": "1.0.3", @@ -7187,7 +7626,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7916,6 +8354,15 @@ "optional": true, "peer": true }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -8048,6 +8495,21 @@ "node": ">=0.10.0" } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xtend": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", diff --git a/Website/package.json b/Website/package.json index 3db685d..f0005d6 100644 --- a/Website/package.json +++ b/Website/package.json @@ -11,6 +11,7 @@ "prepipeline": "node scripts/copyStub.js" }, "dependencies": { + "@azure/identity": "^4.13.0", "@joint/core": "^4.1.3", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", diff --git a/azure-pipelines-deploy-jobs.yml b/azure-pipelines-deploy-jobs.yml index b86e717..58cce5e 100644 --- a/azure-pipelines-deploy-jobs.yml +++ b/azure-pipelines-deploy-jobs.yml @@ -23,6 +23,15 @@ parameters: - name: websiteName type: string default: '' + - name: adoOrganizationUrl + type: string + default: '' + - name: adoProjectName + type: string + default: '' + - name: adoRepositoryName + type: string + default: '' steps: - task: AzureCLI@2 @@ -32,10 +41,46 @@ steps: scriptType: "pscore" scriptLocation: "inlineScript" inlineScript: | + # Create resource group az group create --name ${{ parameters.azureResourceGroupName }} --location ${{ parameters.azureLocation }} - $jsonResult = az deployment group create --resource-group ${{ parameters.azureResourceGroupName }} --template-file ${{ parameters.bicepTemplateFile }} --parameters websitePassword=${{ parameters.websitePassword }} --parameters sessionSecret=${{ parameters.websiteSessionSecret }} --parameters solutionId=${{ parameters.websiteName }} | ConvertFrom-Json + + # Deploy bicep template + $jsonResult = az deployment group create ` + --resource-group ${{ parameters.azureResourceGroupName }} ` + --template-file ${{ parameters.bicepTemplateFile }} ` + --parameters websitePassword="${{ parameters.websitePassword }}" ` + --parameters sessionSecret="${{ parameters.websiteSessionSecret }}" ` + --parameters solutionId="${{ parameters.websiteName }}" ` + --parameters adoOrganizationUrl="${{ parameters.adoOrganizationUrl }}" ` + --parameters adoProjectName="${{ parameters.adoProjectName }}" ` + --parameters adoRepositoryName="${{ parameters.adoRepositoryName }}" ` + | ConvertFrom-Json + + # Extract outputs $webAppName = $jsonResult.properties.outputs.webAppName.value + $principalId = $jsonResult.properties.outputs.managedIdentityPrincipalId.value + $webAppUrl = $jsonResult.properties.outputs.webAppUrl.value + + # Set pipeline variables Write-Host "##vso[task.setvariable variable=webAppName]$webAppName" + Write-Host "##vso[task.setvariable variable=managedIdentityPrincipalId]$principalId" + Write-Host "##vso[task.setvariable variable=webAppUrl]$webAppUrl" + + # Output for manual ADO setup + Write-Host "==================================================" + Write-Host "MANAGED IDENTITY SETUP REQUIRED:" + Write-Host "==================================================" + Write-Host "Web App: $webAppName" + Write-Host "Managed Identity Principal ID: $principalId" + Write-Host "Web App URL: $webAppUrl" + Write-Host "" + Write-Host "MANUAL STEPS REQUIRED:" + Write-Host "1. Go to Azure DevOps Organization Settings > Users" + Write-Host "2. Add user with Principal ID: $principalId" + Write-Host "3. Grant 'Basic' access level" + Write-Host "4. Add to project '${{ parameters.adoProjectName }}' with appropriate permissions" + Write-Host "5. Grant repository access to '${{ parameters.adoRepositoryName }}'" + Write-Host "==================================================" - download: current artifact: WebApp @@ -46,3 +91,25 @@ steps: appType: "webAppLinux" appName: $(webAppName) package: "$(Pipeline.Workspace)/WebApp/WebApp.zip" + + - task: AzureCLI@2 + displayName: "Verify Managed Identity" + inputs: + azureSubscription: ${{ parameters.azureServiceConnectionName }} + scriptType: "pscore" + scriptLocation: "inlineScript" + inlineScript: | + # Test if managed identity can get tokens + Write-Host "Testing Managed Identity token acquisition..." + + # Get token using managed identity (this tests if it's working) + try { + $token = az account get-access-token --resource "https://app.vssps.visualstudio.com/" --query accessToken --output tsv + if ($token) { + Write-Host "Your managed identity is working" + } else { + Write-Host "Failed to acquire token" + } + } catch { + Write-Host "Error testing managed identity: $_" + } diff --git a/azure-pipelines-external.yml b/azure-pipelines-external.yml index 6c55dce..8b510ca 100644 --- a/azure-pipelines-external.yml +++ b/azure-pipelines-external.yml @@ -20,6 +20,9 @@ # - AzureClientId # - AzureClientSecret # - DataverseUrl +# - AdoWikiName +# - AdoWikiPagePath +# - DataModelViewerRepositoryName trigger: none pr: none @@ -88,4 +91,7 @@ stages: azureLocation: $(AzureLocation) websitePassword: $(WebsitePassword) websiteSessionSecret: $(WebsiteSessionSecret) - websiteName: $(WebsiteName) \ No newline at end of file + websiteName: $(WebsiteName) + adoOrganizationUrl: $(System.CollectionUri) + adoProjectName: $(System.TeamProject) + adoRepositoryName: $(DataModelViewerRepositoryName) \ No newline at end of file From c742207188cd15091423ba6675e3a50e4f896fb5 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 14 Oct 2025 16:57:32 +0200 Subject: [PATCH 06/51] feat: inital attempt at managed identity authentication for digram saving in ADO Repo. ESLINT errors --- Website/components/diagramview/DiagramContainer.tsx | 2 +- Website/components/diagramview/SidebarDiagramView.tsx | 3 +-- .../diagramview/layout/GridLayoutManager.ts | 11 +++++------ azure-pipelines-external.yml | 8 ++++---- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/Website/components/diagramview/DiagramContainer.tsx b/Website/components/diagramview/DiagramContainer.tsx index e3d6716..935a35f 100644 --- a/Website/components/diagramview/DiagramContainer.tsx +++ b/Website/components/diagramview/DiagramContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useRef } from "react"; +import React from "react"; import { Box } from "@mui/material"; import { useDiagramView } from "@/contexts/DiagramViewContext"; diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index 84181b5..3b13580 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -1,6 +1,5 @@ -import { Box, IconButton, Tooltip, Typography, Grid, Divider } from '@mui/material'; +import { Box, Tooltip, Typography, Grid, Divider } from '@mui/material'; import React, { useState } from 'react'; -import { useDiagramView } from '@/contexts/DiagramViewContext'; import { AddSquareIcon } from '@/lib/icons'; import { EntitySelectionPane } from './panes/EntitySelectionPane'; diff --git a/Website/components/diagramview/layout/GridLayoutManager.ts b/Website/components/diagramview/layout/GridLayoutManager.ts index 90d70b8..88a4eb4 100644 --- a/Website/components/diagramview/layout/GridLayoutManager.ts +++ b/Website/components/diagramview/layout/GridLayoutManager.ts @@ -58,7 +58,7 @@ export const calculateGridLayout = ( const maxY = Math.max(...existingPositions.map(pos => pos.y + pos.height)); // Get sample entity dimensions for spacing calculations - const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }); + const sampleDimensions = estimateEntityDimensions(); // Start new entities to the right of existing ones, or on the next row startColumn = Math.floor((maxX + padding - margin) / (sampleDimensions.width + padding)); @@ -70,7 +70,7 @@ export const calculateGridLayout = ( } // Determine how many columns can fit based on actual entity dimensions - const sampleEntityDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }); + const sampleEntityDimensions = estimateEntityDimensions(); const actualEntityWidth = sampleEntityDimensions.width; const maxColumns = Math.max(1, Math.floor((containerWidth - margin * 2 + padding) / (actualEntityWidth + padding))); @@ -80,8 +80,7 @@ export const calculateGridLayout = ( let currentRow = startRow; for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - const entityDimensions = estimateEntityDimensions(entity); + const entityDimensions = estimateEntityDimensions(); const height = entityDimensions.height; const width = entityDimensions.width; @@ -134,7 +133,7 @@ export const calculateGridLayout = ( } } - const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }); + const sampleDimensions = estimateEntityDimensions(); const gridWidth = Math.min(entities.length, maxColumns) * sampleDimensions.width + (Math.min(entities.length, maxColumns) - 1) * padding; const gridHeight = (currentRow + 1) * (sampleDimensions.height + padding) - padding; @@ -151,7 +150,7 @@ export const calculateGridLayout = ( /** * Estimates entity dimensions based on content and diagram type */ -export const estimateEntityDimensions = (entity: EntityType): { width: number; height: number } => { +export const estimateEntityDimensions = (): { width: number; height: number } => { return { width: 200, height: 80 diff --git a/azure-pipelines-external.yml b/azure-pipelines-external.yml index 8b510ca..24a3966 100644 --- a/azure-pipelines-external.yml +++ b/azure-pipelines-external.yml @@ -22,7 +22,7 @@ # - DataverseUrl # - AdoWikiName # - AdoWikiPagePath -# - DataModelViewerRepositoryName +# - AdoRepositoryName trigger: none pr: none @@ -60,7 +60,7 @@ stages: steps: - checkout: none - script: | - git clone https://github.com/delegateas/DataModelViewer.git $(Build.SourcesDirectory) + git clone --branch "features/diagram-2.0.0-patches" https://github.com/delegateas/DataModelViewer.git $(Build.SourcesDirectory) displayName: "Clone DataModelViewer public repository" - template: azure-pipelines-build-jobs.yml parameters: @@ -81,7 +81,7 @@ stages: steps: - checkout: none - script: | - git clone https://github.com/delegateas/DataModelViewer.git $(Build.SourcesDirectory) + git clone --branch "features/diagram-2.0.0-patches" https://github.com/delegateas/DataModelViewer.git $(Build.SourcesDirectory) displayName: "Clone DataModelViewer public repository" - template: azure-pipelines-deploy-jobs.yml parameters: @@ -94,4 +94,4 @@ stages: websiteName: $(WebsiteName) adoOrganizationUrl: $(System.CollectionUri) adoProjectName: $(System.TeamProject) - adoRepositoryName: $(DataModelViewerRepositoryName) \ No newline at end of file + adoRepositoryName: $(AdoRepositoryName) \ No newline at end of file From 423bd9f02a3d471d0918a2653ad70f8f3ca26cc6 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 14 Oct 2025 17:39:17 +0200 Subject: [PATCH 07/51] fix: build error in deploy step caused by node_modules folder zipping twice. --- Infrastructure/main.bicep | 29 +- .../auth/azuredevops/AzureDevOpsService.ts | 278 ++++++++++++++++++ azure-pipelines-build-jobs.yml | 10 + 3 files changed, 301 insertions(+), 16 deletions(-) create mode 100644 Website/app/api/auth/azuredevops/AzureDevOpsService.ts diff --git a/Infrastructure/main.bicep b/Infrastructure/main.bicep index a3a63c8..876df65 100644 --- a/Infrastructure/main.bicep +++ b/Infrastructure/main.bicep @@ -1,20 +1,15 @@ -@description('The name of the web app') param solutionId string -@description('Website password') @secure() param websitePassword string -@description('Session secret') @secure() param sessionSecret string -@description('The location for all resources') -var location = resourceGroup().location -@description('Azure DevOps Organization URL') + param adoOrganizationUrl string = '' -@description('Azure DevOps Project Name') param adoProjectName string = '' -@description('Azure DevOps Repository Name') param adoRepositoryName string = '' +var location = resourceGroup().location + @description('Create an App Service Plan') resource appServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = { name: 'asp-${solutionId}' @@ -50,6 +45,14 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { name: 'WebsiteSessionSecret' value: sessionSecret } + { + name: 'WEBSITE_NODE_DEFAULT_VERSION' + value: '~20' + } + { + name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' + value: 'false' + } { name: 'ADO_ORGANIZATION_URL' value: adoOrganizationUrl @@ -59,21 +62,15 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { value: adoProjectName } { - name: 'ADO_REPOSITORY_NAME' + name: 'AdoRepositoryName' value: adoRepositoryName } - { - name: 'WEBSITE_NODE_DEFAULT_VERSION' - value: '~20' - } ] } } } -@description('Output the web app name') +@description('Output the web app name and managed identity info') output webAppName string = webApp.name -@description('Output the managed identity principal ID') output managedIdentityPrincipalId string = webApp.identity.principalId output managedIdentityClientId string = webApp.identity.tenantId -output webAppUrl string = 'https://${webApp.properties.defaultHostName}' diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts new file mode 100644 index 0000000..91117a6 --- /dev/null +++ b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts @@ -0,0 +1,278 @@ +import { managedAuth } from './ManagedIdentityAuthService'; + +interface CreateFileOptions { + filePath: string; + content: any; // Will be JSON.stringify'd + commitMessage?: string; + branch?: string; + repositoryName?: string; // Optional override +} + +interface LoadFileOptions { + filePath: string; + branch?: string; + repositoryName?: string; // Optional override +} + +interface GitFileResponse { + objectId: string; + gitObjectType: string; + commitId: string; + path: string; + content?: string; +} + +interface GitCommitResponse { + commitId: string; + author: { + name: string; + email: string; + date: string; + }; + committer: { + name: string; + email: string; + date: string; + }; + comment: string; +} + +class AzureDevOpsError extends Error { + constructor(message: string, public statusCode?: number, public response?: any) { + super(message); + this.name = 'AzureDevOpsError'; + } +} + +async function getRepositoryId(repositoryName?: string): Promise { + const config = managedAuth.getConfig(); + const repoName = repositoryName || process.env.AdoRepositoryName || config.repositoryName; + + if (!repoName) { + throw new AzureDevOpsError('Repository name not found. Set AdoRepositoryName environment variable or pass repositoryName parameter.'); + } + + try { + const repoUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${encodeURIComponent(repoName)}?api-version=7.0`; + const response = await managedAuth.makeAuthenticatedRequest(repoUrl); + + if (!response.ok) { + const errorText = await response.text(); + throw new AzureDevOpsError(`Failed to get repository info for '${repoName}': ${response.status} - ${errorText}`, response.status); + } + + const repoData = await response.json(); + return repoData.id; + + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new AzureDevOpsError(`Unexpected error getting repository ID for '${repoName}': ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Creates a JSON file in the Azure DevOps Git repository + * @param options Configuration for file creation + * @returns Promise with commit information + */ +export async function createFileInRepo(options: CreateFileOptions): Promise { + const { + filePath, + content, + commitMessage = `Add ${filePath}`, + branch = 'main', + repositoryName + } = options; + + try { + // Get ADO configuration + const config = managedAuth.getConfig(); + + // Validate inputs + if (!filePath || content === undefined) { + throw new AzureDevOpsError('File path and content are required'); + } + + // Get repository ID from environment variable or parameter + const repositoryId = await getRepositoryId(repositoryName); + + // Convert content to JSON string and then to base64 + const jsonContent = JSON.stringify(content, null, 2); + const base64Content = Buffer.from(jsonContent).toString('base64'); + + // Get the latest commit ID for the branch (needed for push operation) + const refsUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryId}/refs?filter=heads/${branch}&api-version=7.0`; + const refsResponse = await managedAuth.makeAuthenticatedRequest(refsUrl); + + if (!refsResponse.ok) { + const errorText = await refsResponse.text(); + throw new AzureDevOpsError(`Failed to get branch refs: ${refsResponse.status} - ${errorText}`, refsResponse.status); + } + + const refsData = await refsResponse.json(); + const currentCommitId = refsData.value?.[0]?.objectId; + + if (!currentCommitId) { + throw new AzureDevOpsError(`Branch '${branch}' not found or has no commits`); + } + + // Create the push payload + const pushPayload = { + refUpdates: [ + { + name: `refs/heads/${branch}`, + oldObjectId: currentCommitId + } + ], + commits: [ + { + comment: commitMessage, + author: { + name: "DataModelViewer", + email: "system@datamodelviewer.com" + }, + changes: [ + { + changeType: "add", + item: { + path: filePath.startsWith('/') ? filePath : `/${filePath}` + }, + newContent: { + content: base64Content, + contentType: "base64encoded" + } + } + ] + } + ] + }; + + // Push the changes + const pushUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryId}/pushes?api-version=7.0`; + const pushResponse = await managedAuth.makeAuthenticatedRequest(pushUrl, { + method: 'POST', + body: JSON.stringify(pushPayload) + }); + + if (!pushResponse.ok) { + const errorText = await pushResponse.text(); + throw new AzureDevOpsError(`Failed to create file: ${pushResponse.status} - ${errorText}`, pushResponse.status); + } + + const pushData = await pushResponse.json(); + + return { + commitId: pushData.commits[0].commitId, + author: pushData.commits[0].author, + committer: pushData.commits[0].committer, + comment: pushData.commits[0].comment + }; + + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new AzureDevOpsError(`Unexpected error creating file: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Loads a JSON file from the Azure DevOps Git repository + * @param options Configuration for file loading + * @returns Promise with parsed JSON content + */ +export async function loadFileFromRepo(options: LoadFileOptions): Promise { + const { + filePath, + branch = 'main', + repositoryName + } = options; + + try { + // Get ADO configuration + const config = managedAuth.getConfig(); + + // Validate inputs + if (!filePath) { + throw new AzureDevOpsError('File path is required'); + } + + // Get repository ID from environment variable or parameter + const repositoryId = await getRepositoryId(repositoryName); + + // Construct the API URL for getting file content + const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + const fileUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryId}/items?path=/${normalizedPath}&version=${branch}&includeContent=true&api-version=7.0`; + + const response = await managedAuth.makeAuthenticatedRequest(fileUrl); + + if (!response.ok) { + if (response.status === 404) { + throw new AzureDevOpsError(`File not found: ${filePath}`, 404); + } + const errorText = await response.text(); + throw new AzureDevOpsError(`Failed to load file: ${response.status} - ${errorText}`, response.status); + } + + const fileData: GitFileResponse = await response.json(); + + if (!fileData.content) { + throw new AzureDevOpsError(`File content is empty: ${filePath}`); + } + + // Decode base64 content and parse JSON + const decodedContent = Buffer.from(fileData.content, 'base64').toString('utf-8'); + + try { + return JSON.parse(decodedContent) as T; + } catch (parseError) { + throw new AzureDevOpsError(`Failed to parse JSON content: ${parseError instanceof Error ? parseError.message : String(parseError)}`); + } + + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new AzureDevOpsError(`Unexpected error loading file: ${error instanceof Error ? error.message : String(error)}`); + } +} + +// Helper function to get repository information using environment variable +export async function getRepositoryInfo(repositoryName?: string): Promise<{ id: string; name: string; webUrl: string }> { + try { + const config = managedAuth.getConfig(); + const repoName = repositoryName || process.env.AdoRepositoryName || config.repositoryName; + + if (!repoName) { + throw new AzureDevOpsError('Repository name not found. Set AdoRepositoryName environment variable or pass repositoryName parameter.'); + } + + const repoUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${encodeURIComponent(repoName)}?api-version=7.0`; + const response = await managedAuth.makeAuthenticatedRequest(repoUrl); + + if (!response.ok) { + const errorText = await response.text(); + throw new AzureDevOpsError(`Failed to get repository info: ${response.status} - ${errorText}`, response.status); + } + + const repoData = await response.json(); + + return { + id: repoData.id, + name: repoData.name, + webUrl: repoData.webUrl + }; + + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new AzureDevOpsError(`Unexpected error getting repository info: ${error instanceof Error ? error.message : String(error)}`); + } +} + +// Export types for external use +export type { CreateFileOptions, LoadFileOptions, GitCommitResponse, GitFileResponse }; +export { AzureDevOpsError }; \ No newline at end of file diff --git a/azure-pipelines-build-jobs.yml b/azure-pipelines-build-jobs.yml index fa3fca2..706a3be 100644 --- a/azure-pipelines-build-jobs.yml +++ b/azure-pipelines-build-jobs.yml @@ -67,6 +67,16 @@ steps: workingDirectory: $(Build.SourcesDirectory)/Website displayName: "Build Next.js app" + - script: | + Write-Host "Cleaning up node_modules before packaging..." + Remove-Item -Path "$(Build.SourcesDirectory)/Website/.next/standalone/node_modules" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path "$(Build.SourcesDirectory)/Website/node_modules" -Recurse -Force -ErrorAction SilentlyContinue + + # List contents to verify + Write-Host "Contents of standalone folder:" + Get-ChildItem "$(Build.SourcesDirectory)/Website/.next/standalone" -Recurse | Select-Object Name, Length + workingDirectory: $(Build.SourcesDirectory)/Website + displayName: "Clean node_modules" - task: ArchiveFiles@2 inputs: From 70612d3493e6ca7cc01ab97bdfb7394b0df1e362 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 14 Oct 2025 17:46:35 +0200 Subject: [PATCH 08/51] chore: lint failures --- Website/app/api/auth/azuredevops/AzureDevOpsService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts index 91117a6..50c1826 100644 --- a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts +++ b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts @@ -2,7 +2,7 @@ import { managedAuth } from './ManagedIdentityAuthService'; interface CreateFileOptions { filePath: string; - content: any; // Will be JSON.stringify'd + content: string; // Will be JSON.stringify'd commitMessage?: string; branch?: string; repositoryName?: string; // Optional override @@ -38,7 +38,7 @@ interface GitCommitResponse { } class AzureDevOpsError extends Error { - constructor(message: string, public statusCode?: number, public response?: any) { + constructor(message: string, public statusCode?: number, public response?: unknown) { super(message); this.name = 'AzureDevOpsError'; } @@ -183,7 +183,7 @@ export async function createFileInRepo(options: CreateFileOptions): Promise(options: LoadFileOptions): Promise { +export async function loadFileFromRepo(options: LoadFileOptions): Promise { const { filePath, branch = 'main', From 299e93d81e48c88da949c392c9061972d5bdc37b Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 14 Oct 2025 19:59:25 +0200 Subject: [PATCH 09/51] feat: Diagram header toolbar with actions. Initial attempt at creating documents in ADO. Also a waiting modal. --- .../auth/azuredevops/AzureDevOpsService.ts | 5 +- .../azuredevops/ManagedIdentityAuthService.ts | 2 +- .../app/api/diagram/repository-info/route.ts | 31 +++++ Website/app/api/diagram/save/route.ts | 80 +++++++++++ Website/app/globals.css | 32 +++++ .../diagramview/DiagramHeaderToolbar.tsx | 65 +++++++++ .../components/diagramview/DiagramView.tsx | 9 +- .../diagramview/SidebarDiagramView.tsx | 68 +++++----- .../diagramview/modals/SaveProgressModal.tsx | 126 ++++++++++++++++++ .../smaller-components/HeaderDropdownMenu.tsx | 101 ++++++++++++++ .../smaller-components/HeaderMenuItem.tsx | 55 ++++++++ Website/components/shared/Header.tsx | 7 +- Website/components/shared/Sidebar.tsx | 2 +- Website/hooks/useDiagramSave.ts | 46 +++++++ Website/lib/diagram-serialization.ts | 85 ++++++++++++ Website/lib/icons.tsx | 20 +++ Website/public/AzureDevOps.svg | 1 + azure-pipelines-build-jobs.yml | 14 +- 18 files changed, 703 insertions(+), 46 deletions(-) create mode 100644 Website/app/api/diagram/repository-info/route.ts create mode 100644 Website/app/api/diagram/save/route.ts create mode 100644 Website/components/diagramview/DiagramHeaderToolbar.tsx create mode 100644 Website/components/diagramview/modals/SaveProgressModal.tsx create mode 100644 Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx create mode 100644 Website/components/diagramview/smaller-components/HeaderMenuItem.tsx create mode 100644 Website/hooks/useDiagramSave.ts create mode 100644 Website/lib/diagram-serialization.ts create mode 100644 Website/public/AzureDevOps.svg diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts index 50c1826..d82a867 100644 --- a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts +++ b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts @@ -2,7 +2,7 @@ import { managedAuth } from './ManagedIdentityAuthService'; interface CreateFileOptions { filePath: string; - content: string; // Will be JSON.stringify'd + content: string; commitMessage?: string; branch?: string; repositoryName?: string; // Optional override @@ -99,8 +99,7 @@ export async function createFileInRepo(options: CreateFileOptions): Promise { const token = await this.getAccessToken(); - + return fetch(url, { ...options, headers: { diff --git a/Website/app/api/diagram/repository-info/route.ts b/Website/app/api/diagram/repository-info/route.ts new file mode 100644 index 0000000..1f290ea --- /dev/null +++ b/Website/app/api/diagram/repository-info/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + // Extract organization name from the URL + const organizationUrl = process.env.ADO_ORGANIZATION_URL || ''; + const repositoryName = process.env.AdoRepositoryName || ''; + + // Parse organization name from URL (e.g., "https://dev.azure.com/MedlemX/" -> "MedlemX") + let organizationName = ''; + if (organizationUrl) { + const urlMatch = organizationUrl.match(/https:\/\/dev\.azure\.com\/([^\/]+)\/?/); + if (urlMatch && urlMatch[1]) { + organizationName = urlMatch[1]; + } + } + + return NextResponse.json({ + organization: organizationName, + repository: repositoryName, + project: process.env.ADO_PROJECT_NAME || '' + }); + + } catch (error) { + console.error('Error getting repository info:', error); + return NextResponse.json( + { error: 'Failed to get repository information' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/Website/app/api/diagram/save/route.ts b/Website/app/api/diagram/save/route.ts new file mode 100644 index 0000000..9448354 --- /dev/null +++ b/Website/app/api/diagram/save/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createFileInRepo } from '../../auth/azuredevops/AzureDevOpsService'; + +export interface DiagramSaveData { + id: string; + name: string; + version: string; + createdAt: string; + updatedAt: string; + metadata: { + zoom: number; + translate: { x: number; y: number }; + canvasSize?: { width: number; height: number }; + }; + entities: Array<{ + id: string; + type: string; + position: { x: number; y: number }; + size: { width: number; height: number }; + label: string; + }>; +} + +export async function POST(request: NextRequest) { + try { + const diagramData: DiagramSaveData = await request.json(); + + // Validate required fields + if (!diagramData.id || !diagramData.name) { + return NextResponse.json( + { error: 'Diagram ID and name are required' }, + { status: 400 } + ); + } + + // Generate file path based on diagram name and timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `${diagramData.name.replace(/[^a-zA-Z0-9]/g, '_')}_${timestamp}.json`; + const filePath = `diagrams/${fileName}`; + + // Add metadata + const enrichedData: DiagramSaveData = { + ...diagramData, + version: '1.0.0', + updatedAt: new Date().toISOString(), + createdAt: diagramData.createdAt || new Date().toISOString() + }; + + // Save to Azure DevOps repository + const result = await createFileInRepo({ + filePath, + content: JSON.stringify(enrichedData, null, 2), + commitMessage: `Save diagram: ${diagramData.name}`, + branch: 'main' + }); + + return NextResponse.json({ + success: true, + message: 'Diagram saved successfully', + filePath, + commitId: result.commitId, + fileName + }); + + } catch (error) { + console.error('Error saving diagram:', error); + + if (error instanceof Error) { + return NextResponse.json( + { error: `Failed to save diagram: ${error.message}` }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'Failed to save diagram: Unknown error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/Website/app/globals.css b/Website/app/globals.css index 5918581..9f8b3d9 100644 --- a/Website/app/globals.css +++ b/Website/app/globals.css @@ -3,6 +3,38 @@ @theme { --breakpoint-md: 56.25rem; /* 900px to match use-mobile.tsx MOBILE_BREAKPOINT */ + + /* Custom animations for DataModelViewer */ + --animate-data-flow: data-flow 3s ease-in-out infinite; + --animate-pulse-activity: pulse-activity 2s ease-in-out infinite; + + @keyframes data-flow { + 0% { + transform: translateX(-100px); + opacity: 0; + } + 20% { + opacity: 1; + } + 80% { + opacity: 1; + } + 100% { + transform: translateX(100px); + opacity: 0; + } + } + + @keyframes pulse-activity { + 0%, 100% { + transform: scale(1); + opacity: 0.7; + } + 50% { + transform: scale(1.1); + opacity: 1; + } + } } @layer theme { diff --git a/Website/components/diagramview/DiagramHeaderToolbar.tsx b/Website/components/diagramview/DiagramHeaderToolbar.tsx new file mode 100644 index 0000000..6ce8303 --- /dev/null +++ b/Website/components/diagramview/DiagramHeaderToolbar.tsx @@ -0,0 +1,65 @@ +'use client'; + +import React from 'react'; +import { Box } from '@mui/material'; +import { HeaderDropdownMenu, MenuItemConfig } from './smaller-components/HeaderDropdownMenu'; +import { CloudLoadIcon, CloudNewIcon, CloudSaveIcon, FileMenuIcon } from '@/lib/icons'; +import { SaveProgressModal } from './modals/SaveProgressModal'; +import { useDiagramSave } from '@/hooks/useDiagramSave'; + +interface IDiagramHeaderToolbarProps { + // No props needed - actions are handled internally +} + +export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { + const { isSaving, showSaveModal, saveDiagram, closeSaveModal } = useDiagramSave(); + + const handleLoad = () => { + // TODO: Implement load functionality + console.log('Load diagram'); + }; + + const fileMenuItems: MenuItemConfig[] = [ + { + id: 'save', + label: 'Save', + icon: CloudSaveIcon, + action: saveDiagram, + dividerAfter: true, + disabled: true + }, + { + id: 'savenew', + label: 'Save new', + icon: CloudNewIcon, + action: saveDiagram, + dividerAfter: true, + disabled: isSaving + }, + { + id: 'load', + label: 'Load', + icon: CloudLoadIcon, + action: handleLoad + } + ]; + + return ( + <> + + + + + + + ); +}; \ No newline at end of file diff --git a/Website/components/diagramview/DiagramView.tsx b/Website/components/diagramview/DiagramView.tsx index 6dec518..141c33b 100644 --- a/Website/components/diagramview/DiagramView.tsx +++ b/Website/components/diagramview/DiagramView.tsx @@ -4,6 +4,8 @@ import { useSidebar } from "@/contexts/SidebarContext"; import React, { useEffect } from "react"; import { SidebarDiagramView } from "./SidebarDiagramView"; import DiagramContainer from "./DiagramContainer"; +import { DiagramHeaderToolbar } from "./DiagramHeaderToolbar"; +import { Box } from "@mui/material"; interface IDiagramViewProps { @@ -18,6 +20,11 @@ export default function DiagramView({ }: IDiagramViewProps) { }, []) return ( - + + + + + + ); } \ No newline at end of file diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index 3b13580..d729dd3 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -35,41 +35,43 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { ]; return ( - - - Diagram Tools - + + + + Diagram Tools + + - + + + Elements + + + + {diagramTools.map((tool) => ( + + + + {tool.icon} + + + + ))} + - - Elements - - - - {diagramTools.map((tool) => ( - - - - {tool.icon} - - - - ))} - - - + + ); } \ No newline at end of file diff --git a/Website/components/diagramview/modals/SaveProgressModal.tsx b/Website/components/diagramview/modals/SaveProgressModal.tsx new file mode 100644 index 0000000..b6d7157 --- /dev/null +++ b/Website/components/diagramview/modals/SaveProgressModal.tsx @@ -0,0 +1,126 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + Box, + Typography, +} from '@mui/material'; + +interface SaveProgressModalProps { + open: boolean; + onClose?: () => void; +} + +export const SaveProgressModal: React.FC = ({ open, onClose }) => { + const [repositoryInfo, setRepositoryInfo] = useState('Loading...'); + + useEffect(() => { + if (open) { + fetch('/api/diagram/repository-info') + .then(response => response.json()) + .then(data => { + if (data.organization && data.repository) { + setRepositoryInfo(`${data.organization}/${data.repository}`); + } else { + setRepositoryInfo('Azure DevOps Repository'); + } + }) + .catch(() => { + setRepositoryInfo('Azure DevOps Repository'); + }); + } + }, [open]); + return ( + + + + {/* Title */} + + Saving Diagram to Azure DevOps + + + Connected to {repositoryInfo} + + + {/* Animation Container */} + + {/* DMV Logo (Left) */} + + + + DataModel +
+ Viewer +
+
+ + {/* Data Flow Animation (Center) */} + + + + + + {/* Azure DevOps Logo (Right) */} + + + + Azure +
+ DevOps +
+
+
+ + + Your diagram is being securely saved to the repository + +
+
+
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx new file mode 100644 index 0000000..26d4a11 --- /dev/null +++ b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx @@ -0,0 +1,101 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Menu, + MenuItem, + ListItemIcon, + ListItemText, + Divider, + Typography +} from '@mui/material'; +import HeaderMenuItem from './HeaderMenuItem'; + +export interface MenuItemConfig { + id: string; + label: string; + icon?: React.ReactNode; + action: () => void; + disabled?: boolean; + dividerAfter?: boolean; +} + +interface HeaderDropdownMenuProps { + triggerIcon: React.ReactNode; + triggerLabel: string; + triggerTooltip?: string; + menuItems: MenuItemConfig[]; + isNew?: boolean; + disabled?: boolean; +} + +export const HeaderDropdownMenu: React.FC = ({ + triggerIcon, + triggerLabel, + triggerTooltip, + menuItems, + isNew = false, + disabled = false +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const isOpen = Boolean(anchorEl); + + const handleMenuClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + const handleMenuItemClick = (action: () => void) => { + handleMenuClose(); + action(); + }; + + return ( + <> + + + + {menuItems.map((item, index) => ( + handleMenuItemClick(item.action)} + disabled={item.disabled} + > + {item.icon && ( + + {item.icon} + + )} + {item.label} + + ))} + + + ); +}; \ No newline at end of file diff --git a/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx b/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx new file mode 100644 index 0000000..b7facda --- /dev/null +++ b/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx @@ -0,0 +1,55 @@ +import { Tooltip, Box, Badge, alpha, Typography, Button } from '@mui/material'; +import React from 'react' + +interface IHeaderMenuItemProps { + icon: React.ReactNode; + label: string; + tooltip?: string; + new?: boolean; + disabled?: boolean; + action?: (event: React.MouseEvent) => void; +} + +const HeaderMenuItem = ({ icon, label, tooltip, new: isNew, disabled, action }: IHeaderMenuItemProps) => { + return ( + + + { + if (action) { + action(event); + } + }} + className="hover:cursor-pointer" + > + alpha(theme.palette.primary.main, 0.16), + color: disabled ? 'text.disabled' : 'text.primary', + } + }} + > + + {icon} + + + {label} + + + + + + ); +} + +export default HeaderMenuItem diff --git a/Website/components/shared/Header.tsx b/Website/components/shared/Header.tsx index 5001ce9..c72417c 100644 --- a/Website/components/shared/Header.tsx +++ b/Website/components/shared/Header.tsx @@ -1,7 +1,7 @@ import { useLoading } from '@/hooks/useLoading'; import { useAuth } from '@/contexts/AuthContext'; import { useSettings } from '@/contexts/SettingsContext'; -import { useRouter } from 'next/navigation'; +import { useRouter, usePathname } from 'next/navigation'; import { AppBar, Toolbar, Box, LinearProgress, Button, Stack } from '@mui/material'; import SettingsPane from './elements/SettingsPane'; import { useIsMobile } from '@/hooks/use-mobile'; @@ -18,12 +18,15 @@ const Header = ({ }: HeaderProps) => { const { isOpen: sidebarOpen, expand } = useSidebar(); const isMobile = useIsMobile(); const router = useRouter(); + const pathname = usePathname(); const { isAuthenticating, isRedirecting, } = useLoading(); + const isDiagramRoute = pathname === '/diagram'; + const handleSettingsClick = () => { setSettingsOpen(true); }; @@ -53,7 +56,7 @@ const Header = ({ }: HeaderProps) => { position="sticky" color="transparent" elevation={0} - className="w-full top-0 z-0 border-b h-header max-h-header" + className={`w-full top-0 z-0 h-header max-h-header ${isDiagramRoute ? 'border-b border-dashed' : 'border-b'}`} sx={{ bgcolor: 'background.paper', borderColor: 'border.main' }} > diff --git a/Website/components/shared/Sidebar.tsx b/Website/components/shared/Sidebar.tsx index 4c3b4fe..cf440dd 100644 --- a/Website/components/shared/Sidebar.tsx +++ b/Website/components/shared/Sidebar.tsx @@ -191,7 +191,7 @@ const Sidebar = ({ }: SidebarProps) => { - + ))}
{isOpen && element != null && ( diff --git a/Website/hooks/useDiagramSave.ts b/Website/hooks/useDiagramSave.ts new file mode 100644 index 0000000..b689e10 --- /dev/null +++ b/Website/hooks/useDiagramSave.ts @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; +import { DiagramSerializationService } from '@/lib/diagram-serialization'; + +export const useDiagramSave = () => { + const { getGraph, zoom, translate } = useDiagramView(); + const [isSaving, setIsSaving] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + + const saveDiagram = async () => { + if (isSaving) return; + + setIsSaving(true); + setShowSaveModal(true); + + try { + const graph = getGraph(); + const diagramData = DiagramSerializationService.serializeDiagram(graph, zoom, translate); + const result = await DiagramSerializationService.saveDiagram(diagramData); + + console.log('Diagram saved successfully:', result); + + // TODO: Show success notification to user + alert(`Diagram saved successfully as ${result.fileName}`); + + } catch (error) { + console.error('Error saving diagram:', error); + // TODO: Show error notification to user + alert(`Failed to save diagram: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsSaving(false); + setShowSaveModal(false); + } + }; + + const closeSaveModal = () => { + setShowSaveModal(false); + }; + + return { + isSaving, + showSaveModal, + saveDiagram, + closeSaveModal + }; +}; \ No newline at end of file diff --git a/Website/lib/diagram-serialization.ts b/Website/lib/diagram-serialization.ts new file mode 100644 index 0000000..79eebc6 --- /dev/null +++ b/Website/lib/diagram-serialization.ts @@ -0,0 +1,85 @@ +import { dia } from '@joint/core'; + +export interface SerializedDiagram { + id: string; + name: string; + version: string; + createdAt: string; + updatedAt: string; + metadata: { + zoom: number; + translate: { x: number; y: number }; + canvasSize: { width: number; height: number }; + }; + entities: Array<{ + id: string; + type: string; + position: { x: number; y: number }; + size: { width: number; height: number }; + label: string; + }>; +} + +export class DiagramSerializationService { + static serializeDiagram( + graph: dia.Graph | null, + zoom: number, + translate: { x: number; y: number } + ): SerializedDiagram { + if (!graph) { + throw new Error('No diagram graph available'); + } + + const cells = graph.getCells(); + const entities: any[] = []; + + cells.forEach((cell) => { + if (cell.isElement()) { + // This is an entity/element + const element = cell as dia.Element; + const position = element.position(); + const size = element.size(); + const attrs = element.attributes.attrs || {}; + + entities.push({ + id: element.id, + type: element.attributes.type || 'standard.Rectangle', + position: { x: position.x, y: position.y }, + size: { width: size.width, height: size.height }, + label: attrs.label?.text || attrs.body?.text || `Entity ${entities.length + 1}` + }); + } + }); + + return { + id: crypto.randomUUID(), + name: `Diagram_${new Date().toISOString().split('T')[0]}`, + version: '1.0.0', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: { + zoom, + translate, + canvasSize: { width: 1920, height: 1080 } // Default canvas size + }, + entities + }; + } + + static async saveDiagram(diagramData: SerializedDiagram): Promise { + const response = await fetch('/api/diagram/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(diagramData) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save diagram'); + } + + return response.json(); + } +} \ No newline at end of file diff --git a/Website/lib/icons.tsx b/Website/lib/icons.tsx index e3583a2..afb17dc 100644 --- a/Website/lib/icons.tsx +++ b/Website/lib/icons.tsx @@ -43,4 +43,24 @@ export const InfoIcon = +; + +export const FileMenuIcon = + + +; + +export const CloudLoadIcon = + + + + +export const CloudSaveIcon = + + +; + +export const CloudNewIcon = + + ; \ No newline at end of file diff --git a/Website/public/AzureDevOps.svg b/Website/public/AzureDevOps.svg new file mode 100644 index 0000000..c46851c --- /dev/null +++ b/Website/public/AzureDevOps.svg @@ -0,0 +1 @@ +Icon-devops-261 \ No newline at end of file diff --git a/azure-pipelines-build-jobs.yml b/azure-pipelines-build-jobs.yml index 706a3be..d84eb65 100644 --- a/azure-pipelines-build-jobs.yml +++ b/azure-pipelines-build-jobs.yml @@ -68,13 +68,17 @@ steps: displayName: "Build Next.js app" - script: | - Write-Host "Cleaning up node_modules before packaging..." - Remove-Item -Path "$(Build.SourcesDirectory)/Website/.next/standalone/node_modules" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "$(Build.SourcesDirectory)/Website/node_modules" -Recurse -Force -ErrorAction SilentlyContinue + echo "Cleaning up node_modules before packaging..." + rm -rf "$(Build.SourcesDirectory)/Website/.next/standalone/node_modules" || true + rm -rf "$(Build.SourcesDirectory)/Website/node_modules" || true # List contents to verify - Write-Host "Contents of standalone folder:" - Get-ChildItem "$(Build.SourcesDirectory)/Website/.next/standalone" -Recurse | Select-Object Name, Length + echo "Contents of standalone folder:" + ls -la "$(Build.SourcesDirectory)/Website/.next/standalone/" + + # Check the size of the directory + echo "Size of standalone folder:" + du -sh "$(Build.SourcesDirectory)/Website/.next/standalone/" workingDirectory: $(Build.SourcesDirectory)/Website displayName: "Clean node_modules" From fdd689a1550b95d21d76e591866d2632425ad12f Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 14 Oct 2025 20:07:58 +0200 Subject: [PATCH 10/51] chore: eslint... --- .../diagramview/SidebarDiagramView.tsx | 2 +- .../smaller-components/HeaderDropdownMenu.tsx | 3 +-- .../smaller-components/HeaderMenuItem.tsx | 2 +- Website/hooks/useDiagramSave.ts | 6 ----- Website/lib/diagram-serialization.ts | 22 ++++++++++--------- 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index d729dd3..de1e686 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -1,4 +1,4 @@ -import { Box, Tooltip, Typography, Grid, Divider } from '@mui/material'; +import { Box, Tooltip, Typography, Grid } from '@mui/material'; import React, { useState } from 'react'; import { AddSquareIcon } from '@/lib/icons'; import { EntitySelectionPane } from './panes/EntitySelectionPane'; diff --git a/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx index 26d4a11..46b1ff9 100644 --- a/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx +++ b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx @@ -6,7 +6,6 @@ import { MenuItem, ListItemIcon, ListItemText, - Divider, Typography } from '@mui/material'; import HeaderMenuItem from './HeaderMenuItem'; @@ -81,7 +80,7 @@ export const HeaderDropdownMenu: React.FC = ({ borderRadius: '16px', }} > - {menuItems.map((item, index) => ( + {menuItems.map((item) => ( handleMenuItemClick(item.action)} diff --git a/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx b/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx index b7facda..d60247c 100644 --- a/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx +++ b/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx @@ -1,4 +1,4 @@ -import { Tooltip, Box, Badge, alpha, Typography, Button } from '@mui/material'; +import { Tooltip, Box, Badge, alpha, Typography } from '@mui/material'; import React from 'react' interface IHeaderMenuItemProps { diff --git a/Website/hooks/useDiagramSave.ts b/Website/hooks/useDiagramSave.ts index b689e10..db48951 100644 --- a/Website/hooks/useDiagramSave.ts +++ b/Website/hooks/useDiagramSave.ts @@ -19,14 +19,8 @@ export const useDiagramSave = () => { const result = await DiagramSerializationService.saveDiagram(diagramData); console.log('Diagram saved successfully:', result); - - // TODO: Show success notification to user - alert(`Diagram saved successfully as ${result.fileName}`); - } catch (error) { console.error('Error saving diagram:', error); - // TODO: Show error notification to user - alert(`Failed to save diagram: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsSaving(false); setShowSaveModal(false); diff --git a/Website/lib/diagram-serialization.ts b/Website/lib/diagram-serialization.ts index 79eebc6..92fb9e0 100644 --- a/Website/lib/diagram-serialization.ts +++ b/Website/lib/diagram-serialization.ts @@ -1,5 +1,13 @@ import { dia } from '@joint/core'; +interface SerializedEntity { + id: string; + type: string; + position: { x: number; y: number }; + size: { width: number; height: number }; + label: string; +} + export interface SerializedDiagram { id: string; name: string; @@ -11,13 +19,7 @@ export interface SerializedDiagram { translate: { x: number; y: number }; canvasSize: { width: number; height: number }; }; - entities: Array<{ - id: string; - type: string; - position: { x: number; y: number }; - size: { width: number; height: number }; - label: string; - }>; + entities: SerializedEntity[]; } export class DiagramSerializationService { @@ -31,7 +33,7 @@ export class DiagramSerializationService { } const cells = graph.getCells(); - const entities: any[] = []; + const entities: SerializedEntity[] = []; cells.forEach((cell) => { if (cell.isElement()) { @@ -42,7 +44,7 @@ export class DiagramSerializationService { const attrs = element.attributes.attrs || {}; entities.push({ - id: element.id, + id: element.id.toString(), type: element.attributes.type || 'standard.Rectangle', position: { x: position.x, y: position.y }, size: { width: size.width, height: size.height }, @@ -66,7 +68,7 @@ export class DiagramSerializationService { }; } - static async saveDiagram(diagramData: SerializedDiagram): Promise { + static async saveDiagram(diagramData: SerializedDiagram): Promise { const response = await fetch('/api/diagram/save', { method: 'POST', headers: { From 03a5ebc04297ac60a84e0e8a1bed56f98780855e Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 14 Oct 2025 20:19:32 +0200 Subject: [PATCH 11/51] fix: removed SCM_DO_BUILD_DURING_DEPLOYMENT flag from bicep webservice --- Infrastructure/main.bicep | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Infrastructure/main.bicep b/Infrastructure/main.bicep index 876df65..b2e55ac 100644 --- a/Infrastructure/main.bicep +++ b/Infrastructure/main.bicep @@ -49,10 +49,6 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { name: 'WEBSITE_NODE_DEFAULT_VERSION' value: '~20' } - { - name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' - value: 'false' - } { name: 'ADO_ORGANIZATION_URL' value: adoOrganizationUrl From 9e3e5dccd6c889a424bac5108e96999dc33270d0 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 15 Oct 2025 18:21:45 +0200 Subject: [PATCH 12/51] feat: load and save functionality, also download locally to device introduced --- Website/app/api/diagram/list/route.ts | 47 +++++ Website/app/api/diagram/load/route.ts | 38 ++++ Website/app/api/diagram/save/route.ts | 37 +++- .../diagramview/DiagramHeaderToolbar.tsx | 99 ++++++--- .../diagramview/modals/LoadDiagramModal.tsx | 195 ++++++++++++++++++ ...ProgressModal.tsx => SaveDiagramModal.tsx} | 4 +- .../smaller-components/HeaderDropdownMenu.tsx | 4 +- Website/contexts/DiagramViewContext.tsx | 46 ++++- Website/hooks/useDiagramLoad.ts | 115 +++++++++++ Website/hooks/useDiagramSave.ts | 57 ++++- .../lib/diagram/models/serialized-diagram.ts | 15 ++ .../lib/diagram/models/serialized-entity.ts | 7 + .../services/diagram-deserialization.ts | 135 ++++++++++++ .../services}/diagram-serialization.ts | 62 +++--- Website/lib/icons.tsx | 12 ++ azure-pipelines-build-jobs.yml | 15 -- 16 files changed, 801 insertions(+), 87 deletions(-) create mode 100644 Website/app/api/diagram/list/route.ts create mode 100644 Website/app/api/diagram/load/route.ts create mode 100644 Website/components/diagramview/modals/LoadDiagramModal.tsx rename Website/components/diagramview/modals/{SaveProgressModal.tsx => SaveDiagramModal.tsx} (97%) create mode 100644 Website/hooks/useDiagramLoad.ts create mode 100644 Website/lib/diagram/models/serialized-diagram.ts create mode 100644 Website/lib/diagram/models/serialized-entity.ts create mode 100644 Website/lib/diagram/services/diagram-deserialization.ts rename Website/lib/{ => diagram/services}/diagram-serialization.ts (54%) diff --git a/Website/app/api/diagram/list/route.ts b/Website/app/api/diagram/list/route.ts new file mode 100644 index 0000000..c834e88 --- /dev/null +++ b/Website/app/api/diagram/list/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + // For now, return mock data. In a real implementation, this would + // query the Azure DevOps repository for .json files in the diagrams folder + const mockDiagrams = [ + { + path: 'diagrams/Customer_Management_2025-10-14.json', + name: 'Customer Management', + createdAt: '2025-10-14T10:30:00Z', + updatedAt: '2025-10-14T15:45:00Z', + size: 2048 + }, + { + path: 'diagrams/Order_Processing_2025-10-13.json', + name: 'Order Processing', + createdAt: '2025-10-13T09:15:00Z', + updatedAt: '2025-10-13T16:20:00Z', + size: 1536 + }, + { + path: 'diagrams/User_Authentication_2025-10-12.json', + name: 'User Authentication', + createdAt: '2025-10-12T14:00:00Z', + updatedAt: '2025-10-12T14:30:00Z', + size: 1024 + } + ]; + + // TODO: Replace with actual Azure DevOps API call + // This would use the existing AzureDevOpsService to: + // 1. List files in the diagrams/ folder + // 2. Filter for .json files + // 3. Extract metadata (name, dates, size) + // 4. Return formatted list + + return NextResponse.json(mockDiagrams); + + } catch (error) { + console.error('Error listing diagrams:', error); + return NextResponse.json( + { error: 'Failed to list diagrams' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/Website/app/api/diagram/load/route.ts b/Website/app/api/diagram/load/route.ts new file mode 100644 index 0000000..f1dbf0d --- /dev/null +++ b/Website/app/api/diagram/load/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { loadFileFromRepo } from '../../auth/azuredevops/AzureDevOpsService'; + +export async function POST(request: NextRequest) { + try { + const { filePath } = await request.json(); + + if (!filePath) { + return NextResponse.json( + { error: 'File path is required' }, + { status: 400 } + ); + } + + // Load diagram from Azure DevOps repository + const diagramData = await loadFileFromRepo({ + filePath, + branch: 'main' + }); + + return NextResponse.json(diagramData); + + } catch (error) { + console.error('Error loading diagram:', error); + + if (error instanceof Error) { + return NextResponse.json( + { error: `Failed to load diagram: ${error.message}` }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'Failed to load diagram: Unknown error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/Website/app/api/diagram/save/route.ts b/Website/app/api/diagram/save/route.ts index 9448354..3ca12c2 100644 --- a/Website/app/api/diagram/save/route.ts +++ b/Website/app/api/diagram/save/route.ts @@ -7,6 +7,7 @@ export interface DiagramSaveData { version: string; createdAt: string; updatedAt: string; + overwriteFilePath?: string; // Optional path for overwriting existing files metadata: { zoom: number; translate: { x: number; y: number }; @@ -33,24 +34,48 @@ export async function POST(request: NextRequest) { ); } - // Generate file path based on diagram name and timestamp - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileName = `${diagramData.name.replace(/[^a-zA-Z0-9]/g, '_')}_${timestamp}.json`; - const filePath = `diagrams/${fileName}`; + // Generate file path - use overwrite path if provided, otherwise create new + let fileName: string; + let filePath: string; + + if (diagramData.overwriteFilePath) { + // Overwriting existing file + filePath = diagramData.overwriteFilePath; + fileName = filePath.split('/').pop() || 'diagram.json'; + } else { + // Creating new file + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + fileName = `${diagramData.name.replace(/[^a-zA-Z0-9]/g, '_')}_${timestamp}.json`; + filePath = `diagrams/${fileName}`; + } + + let newVersion = '1.0.0'; // Default for new diagrams + if (diagramData.overwriteFilePath) { + const currentVersion = diagramData.version || '1.0.0'; + const versionParts = currentVersion.split('.').map(Number); + + // Increment patch version (x.y.z -> x.y.z+1) + versionParts[2] = (versionParts[2] || 0) + 1; + newVersion = versionParts.join('.'); + } // Add metadata const enrichedData: DiagramSaveData = { ...diagramData, - version: '1.0.0', + version: newVersion, updatedAt: new Date().toISOString(), createdAt: diagramData.createdAt || new Date().toISOString() }; // Save to Azure DevOps repository + const commitMessage = diagramData.overwriteFilePath + ? `Update diagram: ${diagramData.name}` + : `Save diagram: ${diagramData.name}`; + const result = await createFileInRepo({ filePath, content: JSON.stringify(enrichedData, null, 2), - commitMessage: `Save diagram: ${diagramData.name}`, + commitMessage, branch: 'main' }); diff --git a/Website/components/diagramview/DiagramHeaderToolbar.tsx b/Website/components/diagramview/DiagramHeaderToolbar.tsx index 6ce8303..baede8b 100644 --- a/Website/components/diagramview/DiagramHeaderToolbar.tsx +++ b/Website/components/diagramview/DiagramHeaderToolbar.tsx @@ -1,65 +1,106 @@ 'use client'; -import React from 'react'; -import { Box } from '@mui/material'; +import React, { use } from 'react'; +import { Box, Chip, Typography, useTheme, alpha } from '@mui/material'; import { HeaderDropdownMenu, MenuItemConfig } from './smaller-components/HeaderDropdownMenu'; -import { CloudLoadIcon, CloudNewIcon, CloudSaveIcon, FileMenuIcon } from '@/lib/icons'; -import { SaveProgressModal } from './modals/SaveProgressModal'; +import { CloudLoadIcon, CloudNewIcon, CloudSaveIcon, FileMenuIcon, LocalSaveIcon, NewIcon } from '@/lib/icons'; +import { SaveDiagramModal } from './modals/SaveDiagramModal'; +import { LoadDiagramModal } from './modals/LoadDiagramModal'; import { useDiagramSave } from '@/hooks/useDiagramSave'; +import { useDiagramLoad } from '@/hooks/useDiagramLoad'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; +import { CheckRounded, ErrorRounded } from '@mui/icons-material'; interface IDiagramHeaderToolbarProps { // No props needed - actions are handled internally } export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { - const { isSaving, showSaveModal, saveDiagram, closeSaveModal } = useDiagramSave(); + const { hasLoadedDiagram, loadedDiagramSource} = useDiagramView(); + const { isSaving, showSaveModal, saveDiagramToCloud, saveDiagramLocally, closeSaveModal, createNewDiagram } = useDiagramSave(); + const { + isLoading, + isLoadingList, + showLoadModal, + availableDiagrams, + loadDiagramFromCloud, + loadDiagramFromFile, + openLoadModal, + closeLoadModal + } = useDiagramLoad(); - const handleLoad = () => { - // TODO: Implement load functionality - console.log('Load diagram'); - }; + const theme = useTheme(); const fileMenuItems: MenuItemConfig[] = [ + { + id: 'new', + label: 'New Diagram', + icon: NewIcon, + action: createNewDiagram, + disabled: false, + dividerAfter: true, + }, { id: 'save', - label: 'Save', + label: 'Save to Cloud', icon: CloudSaveIcon, - action: saveDiagram, - dividerAfter: true, - disabled: true + action: saveDiagramToCloud, + disabled: isSaving || !hasLoadedDiagram || loadedDiagramSource !== 'cloud', }, { - id: 'savenew', - label: 'Save new', + id: 'save-new', + label: 'Create in Cloud', icon: CloudNewIcon, - action: saveDiagram, - dividerAfter: true, + action: saveDiagramLocally, disabled: isSaving }, { id: 'load', - label: 'Load', + label: 'Load from Cloud', icon: CloudLoadIcon, - action: handleLoad - } + action: openLoadModal, + disabled: isLoading, + dividerAfter: true, + }, + { + id: 'save-local', + label: 'Download', + icon: LocalSaveIcon, + action: saveDiagramLocally, + disabled: isSaving + }, ]; return ( <> - - + + + + + + : } label={hasLoadedDiagram ? 'Diagram Loaded' : 'No Diagram Loaded'} sx={{ backgroundColor: alpha(hasLoadedDiagram ? theme.palette.primary.main : theme.palette.error.main, 0.5) }} /> - + + ); }; \ No newline at end of file diff --git a/Website/components/diagramview/modals/LoadDiagramModal.tsx b/Website/components/diagramview/modals/LoadDiagramModal.tsx new file mode 100644 index 0000000..1fbc2af --- /dev/null +++ b/Website/components/diagramview/modals/LoadDiagramModal.tsx @@ -0,0 +1,195 @@ +'use client'; + +import React, { useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + Box, + Typography, + List, + ListItem, + ListItemButton, + ListItemText, + ListItemIcon, + Divider, + Button, + CircularProgress, + IconButton +} from '@mui/material'; +import { + CloudDownload as CloudIcon, + Upload as UploadIcon, + Description as FileIcon, + Close as CloseIcon, + DocumentScannerRounded, + FolderRounded, + PolylineRounded +} from '@mui/icons-material'; +import { DiagramFile } from '@/lib/diagram/services/diagram-deserialization'; + +interface LoadDiagramModalProps { + open: boolean; + onClose: () => void; + availableDiagrams: DiagramFile[]; + isLoadingList: boolean; + isLoading: boolean; + onLoadFromCloud: (filePath: string) => void; + onLoadFromFile: (file: File) => void; +} + +export const LoadDiagramModal = ({ + open, + onClose, + availableDiagrams, + isLoadingList, + isLoading, + onLoadFromCloud, + onLoadFromFile +}: LoadDiagramModalProps) => { + const fileInputRef = useRef(null); + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + onLoadFromFile(file); + // Reset the input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const formatFileSize = (bytes: number) => { + const kb = bytes / 1024; + return `${kb.toFixed(1)} KB`; + }; + + return ( + + + Load Diagram + + + + + + + + {/* Upload from file section */} + + + Load from File + + + + + + + + {/* Cloud diagrams section */} + + + Load from Cloud + + + {isLoadingList ? ( + + + + Loading diagrams from Azure DevOps... + + + ) : availableDiagrams.length === 0 ? ( + + + + No diagrams found in the repository + + + ) : ( + + {availableDiagrams.map((diagram) => ( + + onLoadFromCloud(diagram.path)} + disabled={isLoading} + sx={{ + borderRadius: 1, + mb: 0.5, + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + Updated: {formatDate(diagram.updatedAt)} + + + Size: {formatFileSize(diagram.size)} + + + } + /> + + + ))} + + )} + + + + + ); +}; \ No newline at end of file diff --git a/Website/components/diagramview/modals/SaveProgressModal.tsx b/Website/components/diagramview/modals/SaveDiagramModal.tsx similarity index 97% rename from Website/components/diagramview/modals/SaveProgressModal.tsx rename to Website/components/diagramview/modals/SaveDiagramModal.tsx index b6d7157..64a3610 100644 --- a/Website/components/diagramview/modals/SaveProgressModal.tsx +++ b/Website/components/diagramview/modals/SaveDiagramModal.tsx @@ -8,12 +8,12 @@ import { Typography, } from '@mui/material'; -interface SaveProgressModalProps { +interface SaveDiagramModalProps { open: boolean; onClose?: () => void; } -export const SaveProgressModal: React.FC = ({ open, onClose }) => { +export const SaveDiagramModal = ({ open, onClose }: SaveDiagramModalProps) => { const [repositoryInfo, setRepositoryInfo] = useState('Loading...'); useEffect(() => { diff --git a/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx index 46b1ff9..eab8f3d 100644 --- a/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx +++ b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx @@ -85,9 +85,11 @@ export const HeaderDropdownMenu: React.FC = ({ key={item.id} onClick={() => handleMenuItemClick(item.action)} disabled={item.disabled} + divider={item.dividerAfter} + sx={{ borderWidth: 2 }} > {item.icon && ( - + {item.icon} )} diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index afa6559..b940bd2 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -7,6 +7,8 @@ interface DiagramActions { setTranslate: (translate: { x: number; y: number }) => void; addEntity: (position?: { x: number; y: number }, label?: string) => void; getGraph: () => dia.Graph | null; + setLoadedDiagram: (filename: string | null, source: 'cloud' | 'file' | null, filePath?: string | null) => void; + clearDiagram: () => void; } export interface DiagramState extends DiagramActions { @@ -14,6 +16,10 @@ export interface DiagramState extends DiagramActions { zoom: number; isPanning: boolean; translate: { x: number; y: number }; + loadedDiagramFilename: string | null; + loadedDiagramSource: 'cloud' | 'file' | null; + loadedDiagramFilePath: string | null; + hasLoadedDiagram: boolean; } const initialState: DiagramState = { @@ -21,18 +27,26 @@ const initialState: DiagramState = { isPanning: false, translate: { x: 0, y: 0 }, canvas: React.createRef(), + loadedDiagramFilename: null, + loadedDiagramSource: null, + loadedDiagramFilePath: null, + hasLoadedDiagram: false, setZoom: () => { throw new Error("setZoom not initialized yet!"); }, setIsPanning: () => { throw new Error("setIsPanning not initialized yet!"); }, setTranslate: () => { throw new Error("setTranslate not initialized yet!"); }, addEntity: () => { throw new Error("addEntity not initialized yet!"); }, getGraph: () => { throw new Error("getGraph not initialized yet!"); }, + setLoadedDiagram: () => { throw new Error("setLoadedDiagram not initialized yet!"); }, + clearDiagram: () => { throw new Error("clearDiagram not initialized yet!"); }, } type DiagramViewAction = | { type: 'SET_ZOOM', payload: number } | { type: 'SET_IS_PANNING', payload: boolean } - | { type: 'SET_TRANSLATE', payload: { x: number; y: number } }; + | { type: 'SET_TRANSLATE', payload: { x: number; y: number } } + | { type: 'SET_LOADED_DIAGRAM', payload: { filename: string | null; source: 'cloud' | 'file' | null; filePath?: string | null } } + | { type: 'CLEAR_DIAGRAM' }; const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): DiagramState => { switch (action.type) { @@ -42,6 +56,22 @@ const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): Dia return { ...state, isPanning: action.payload } case 'SET_TRANSLATE': return { ...state, translate: action.payload } + case 'SET_LOADED_DIAGRAM': + return { + ...state, + loadedDiagramFilename: action.payload.filename, + loadedDiagramSource: action.payload.source, + loadedDiagramFilePath: action.payload.filePath || null, + hasLoadedDiagram: action.payload.filename !== null + } + case 'CLEAR_DIAGRAM': + return { + ...state, + loadedDiagramFilename: null, + loadedDiagramSource: null, + loadedDiagramFilePath: null, + hasLoadedDiagram: false + } default: return state; } @@ -65,6 +95,18 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { dispatch({ type: 'SET_TRANSLATE', payload: translate }); } + const setLoadedDiagram = (filename: string | null, source: 'cloud' | 'file' | null, filePath?: string | null) => { + dispatch({ type: 'SET_LOADED_DIAGRAM', payload: { filename, source, filePath } }); + } + + const clearDiagram = () => { + // Clear the graph if it exists + if (graphRef.current) { + graphRef.current.clear(); + } + dispatch({ type: 'CLEAR_DIAGRAM' }); + } + // Refs to store graph and paper instances const graphRef = useRef(null); const paperRef = useRef(null); @@ -314,7 +356,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }; return ( - + {children} diff --git a/Website/hooks/useDiagramLoad.ts b/Website/hooks/useDiagramLoad.ts new file mode 100644 index 0000000..cb492bf --- /dev/null +++ b/Website/hooks/useDiagramLoad.ts @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; +import { DiagramDeserializationService, DiagramFile } from '@/lib/diagram/services/diagram-deserialization'; + +export const useDiagramLoad = () => { + const { getGraph, setZoom, setTranslate, setLoadedDiagram } = useDiagramView(); + const [isLoading, setIsLoading] = useState(false); + const [showLoadModal, setShowLoadModal] = useState(false); + const [availableDiagrams, setAvailableDiagrams] = useState([]); + const [isLoadingList, setIsLoadingList] = useState(false); + + const loadAvailableDiagrams = async () => { + setIsLoadingList(true); + try { + const diagrams = await DiagramDeserializationService.getAvailableDiagrams(); + setAvailableDiagrams(diagrams); + } catch (error) { + console.error('Error loading diagram list:', error); + // TODO: Show error notification to user + alert(`Failed to load diagram list: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsLoadingList(false); + } + }; + + const loadDiagramFromCloud = async (filePath: string) => { + if (isLoading) return; + + setIsLoading(true); + + try { + const diagramData = await DiagramDeserializationService.loadDiagramFromCloud(filePath); + const graph = getGraph(); + + DiagramDeserializationService.deserializeDiagram( + diagramData, + graph, + setZoom, + setTranslate, + setLoadedDiagram, + diagramData.name || 'Untitled', + 'cloud', + filePath // Pass the filePath for cloud diagrams + ); + + console.log('Diagram loaded successfully from cloud:', diagramData.name); + + // TODO: Show success notification to user + alert(`Diagram "${diagramData.name}" loaded successfully`); + + setShowLoadModal(false); + + } catch (error) { + console.error('Error loading diagram from cloud:', error); + // TODO: Show error notification to user + alert(`Failed to load diagram: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsLoading(false); + } + }; + + const loadDiagramFromFile = async (file: File) => { + if (isLoading) return; + + setIsLoading(true); + + try { + const diagramData = await DiagramDeserializationService.loadDiagramFromFile(file); + const graph = getGraph(); + + DiagramDeserializationService.deserializeDiagram( + diagramData, + graph, + setZoom, + setTranslate, + setLoadedDiagram, + file.name.replace('.json', ''), + 'file', + undefined // No filePath for local files + ); + + console.log('Diagram loaded successfully from file:', diagramData.name); + + // TODO: Show success notification to user + alert(`Diagram "${diagramData.name}" loaded successfully from file`); + + } catch (error) { + console.error('Error loading diagram from file:', error); + // TODO: Show error notification to user + alert(`Failed to load diagram from file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsLoading(false); + } + }; + + const openLoadModal = () => { + setShowLoadModal(true); + loadAvailableDiagrams(); + }; + + const closeLoadModal = () => { + setShowLoadModal(false); + }; + + return { + isLoading, + isLoadingList, + showLoadModal, + availableDiagrams, + loadDiagramFromCloud, + loadDiagramFromFile, + openLoadModal, + closeLoadModal + }; +}; \ No newline at end of file diff --git a/Website/hooks/useDiagramSave.ts b/Website/hooks/useDiagramSave.ts index db48951..cf0471e 100644 --- a/Website/hooks/useDiagramSave.ts +++ b/Website/hooks/useDiagramSave.ts @@ -1,13 +1,13 @@ import { useState } from 'react'; import { useDiagramView } from '@/contexts/DiagramViewContext'; -import { DiagramSerializationService } from '@/lib/diagram-serialization'; +import { DiagramSerializationService } from '@/lib/diagram/services/diagram-serialization'; export const useDiagramSave = () => { - const { getGraph, zoom, translate } = useDiagramView(); + const { getGraph, zoom, translate, clearDiagram, setLoadedDiagram, loadedDiagramFilename, loadedDiagramSource, loadedDiagramFilePath } = useDiagramView(); const [isSaving, setIsSaving] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false); - const saveDiagram = async () => { + const saveDiagramToCloud = async () => { if (isSaving) return; setIsSaving(true); @@ -16,25 +16,66 @@ export const useDiagramSave = () => { try { const graph = getGraph(); const diagramData = DiagramSerializationService.serializeDiagram(graph, zoom, translate); - const result = await DiagramSerializationService.saveDiagram(diagramData); - console.log('Diagram saved successfully:', result); + // If we have a loaded diagram from cloud, preserve its name for overwriting + if (loadedDiagramSource === 'cloud' && loadedDiagramFilename) { + diagramData.name = loadedDiagramFilename; + } + + // Use overwrite functionality if we have a cloud diagram loaded + const overwriteFilePath = loadedDiagramSource === 'cloud' && loadedDiagramFilePath ? loadedDiagramFilePath : undefined; + const result = await DiagramSerializationService.saveDiagram(diagramData, overwriteFilePath) as { filePath?: string }; + + // Track that this diagram is now loaded from cloud with the correct file path + const resultFilePath = result.filePath || overwriteFilePath; + setLoadedDiagram(diagramData.name, 'cloud', resultFilePath); + + console.log('Diagram saved to cloud successfully:', result); } catch (error) { - console.error('Error saving diagram:', error); + console.error('Error saving diagram to cloud:', error); } finally { setIsSaving(false); setShowSaveModal(false); } }; + const saveDiagramLocally = () => { + if (isSaving) return; + + setIsSaving(true); + + try { + const graph = getGraph(); + const diagramData = DiagramSerializationService.serializeDiagram(graph, zoom, translate); + const downloadResult = DiagramSerializationService.downloadDiagramAsJson(diagramData); + + // Track that this diagram is now loaded as a file + setLoadedDiagram(downloadResult.fileName, 'file'); + } catch (error) { + console.error('Error saving diagram locally:', error); + } finally { + setIsSaving(false); + } + }; + const closeSaveModal = () => { setShowSaveModal(false); }; + const createNewDiagram = () => { + const graph = getGraph(); + if (graph) { + graph.clear(); + } + clearDiagram(); + }; + return { isSaving, showSaveModal, - saveDiagram, - closeSaveModal + saveDiagramToCloud, + saveDiagramLocally, + closeSaveModal, + createNewDiagram }; }; \ No newline at end of file diff --git a/Website/lib/diagram/models/serialized-diagram.ts b/Website/lib/diagram/models/serialized-diagram.ts new file mode 100644 index 0000000..1ccef42 --- /dev/null +++ b/Website/lib/diagram/models/serialized-diagram.ts @@ -0,0 +1,15 @@ +import { SerializedEntity } from "./serialized-entity"; + +export interface SerializedDiagram { + id: string; + name: string; + version: string; + createdAt: string; + updatedAt: string; + metadata: { + zoom: number; + translate: { x: number; y: number }; + canvasSize: { width: number; height: number }; + }; + entities: SerializedEntity[]; +} \ No newline at end of file diff --git a/Website/lib/diagram/models/serialized-entity.ts b/Website/lib/diagram/models/serialized-entity.ts new file mode 100644 index 0000000..592b2f5 --- /dev/null +++ b/Website/lib/diagram/models/serialized-entity.ts @@ -0,0 +1,7 @@ +export interface SerializedEntity { + id: string; + type: string; + position: { x: number; y: number }; + size: { width: number; height: number }; + label: string; +} \ No newline at end of file diff --git a/Website/lib/diagram/services/diagram-deserialization.ts b/Website/lib/diagram/services/diagram-deserialization.ts new file mode 100644 index 0000000..69385c0 --- /dev/null +++ b/Website/lib/diagram/services/diagram-deserialization.ts @@ -0,0 +1,135 @@ +import { dia, shapes } from '@joint/core'; +import { SerializedDiagram } from '../models/serialized-diagram'; +import { SerializedEntity } from '../models/serialized-entity'; + +export interface DiagramFile { + path: string; + name: string; + createdAt: string; + updatedAt: string; + size: number; +} + +export class DiagramDeserializationService { + static async loadDiagramFromCloud(filePath: string): Promise { + const response = await fetch('/api/diagram/load', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ filePath }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to load diagram'); + } + + return response.json(); + } + + static async getAvailableDiagrams(): Promise { + const response = await fetch('/api/diagram/list'); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to get diagram list'); + } + + return response.json(); + } + + static deserializeDiagram( + diagramData: SerializedDiagram, + graph: dia.Graph | null, + setZoom: (zoom: number) => void, + setTranslate: (translate: { x: number; y: number }) => void, + setLoadedDiagram: (filename: string | null, source: 'cloud' | 'file' | null, filePath?: string | null) => void, + filename: string, + source: 'cloud' | 'file', + filePath?: string + ): void { + if (!graph) { + throw new Error('No diagram graph available for deserialization'); + } + + // Clear existing diagram + graph.clear(); + + // Restore zoom and pan + setZoom(diagramData.metadata.zoom); + setTranslate(diagramData.metadata.translate); + + // Set loaded diagram info + setLoadedDiagram(filename, source, filePath); + + // Theme-aware entity colors using MUI CSS variables (same as addEntity) + const colors = [ + { fill: 'var(--mui-palette-primary-main)', stroke: 'var(--mui-palette-primary-dark)' }, + { fill: 'var(--mui-palette-success-main)', stroke: 'var(--mui-palette-success-dark)' }, + { fill: 'var(--mui-palette-warning-main)', stroke: 'var(--mui-palette-warning-dark)' }, + { fill: 'var(--mui-palette-error-main)', stroke: 'var(--mui-palette-error-dark)' }, + { fill: 'var(--mui-palette-secondary-main)', stroke: 'var(--mui-palette-secondary-dark)' }, + { fill: 'var(--mui-palette-info-main)', stroke: 'var(--mui-palette-info-dark)' }, + ]; + + const textColor = 'var(--mui-palette-primary-contrastText)'; + + // Recreate entities + diagramData.entities.forEach((entityData: SerializedEntity, index: number) => { + const colorIndex = index % colors.length; + const color = colors[colorIndex]; + + const rect = new shapes.standard.Rectangle({ + id: entityData.id, + position: entityData.position, + size: entityData.size, + attrs: { + body: { + fill: color.fill, + stroke: color.stroke, + strokeWidth: 2, + rx: 8, + ry: 8 + }, + label: { + text: entityData.label, + fill: textColor, + fontSize: 14, + fontFamily: 'Arial, sans-serif' + } + } + }); + + graph.addCell(rect); + }); + } + + static loadDiagramFromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + try { + const content = event.target?.result as string; + const diagramData = JSON.parse(content) as SerializedDiagram; + + // Validate the diagram data structure + if (!diagramData.id || !diagramData.entities || !diagramData.metadata) { + throw new Error('Invalid diagram file format'); + } + + resolve(diagramData); + } catch (error) { + reject(new Error(`Failed to parse diagram file: ${error instanceof Error ? error.message : 'Unknown error'}`)); + } + }; + + reader.onerror = () => { + reject(new Error('Failed to read diagram file')); + }; + + reader.readAsText(file); + }); + } +} \ No newline at end of file diff --git a/Website/lib/diagram-serialization.ts b/Website/lib/diagram/services/diagram-serialization.ts similarity index 54% rename from Website/lib/diagram-serialization.ts rename to Website/lib/diagram/services/diagram-serialization.ts index 92fb9e0..114c381 100644 --- a/Website/lib/diagram-serialization.ts +++ b/Website/lib/diagram/services/diagram-serialization.ts @@ -1,26 +1,6 @@ import { dia } from '@joint/core'; - -interface SerializedEntity { - id: string; - type: string; - position: { x: number; y: number }; - size: { width: number; height: number }; - label: string; -} - -export interface SerializedDiagram { - id: string; - name: string; - version: string; - createdAt: string; - updatedAt: string; - metadata: { - zoom: number; - translate: { x: number; y: number }; - canvasSize: { width: number; height: number }; - }; - entities: SerializedEntity[]; -} +import { SerializedEntity } from '../models/serialized-entity'; +import { SerializedDiagram } from '../models/serialized-diagram'; export class DiagramSerializationService { static serializeDiagram( @@ -68,13 +48,17 @@ export class DiagramSerializationService { }; } - static async saveDiagram(diagramData: SerializedDiagram): Promise { + static async saveDiagram(diagramData: SerializedDiagram, overwriteFilePath?: string): Promise { + const requestBody = overwriteFilePath + ? { ...diagramData, overwriteFilePath } + : diagramData; + const response = await fetch('/api/diagram/save', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(diagramData) + body: JSON.stringify(requestBody) }); if (!response.ok) { @@ -84,4 +68,34 @@ export class DiagramSerializationService { return response.json(); } + + static downloadDiagramAsJson(diagramData: SerializedDiagram): { fileName: string; success: boolean } { + try { + const jsonString = JSON.stringify(diagramData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + // Create a download URL + const url = URL.createObjectURL(blob); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `${diagramData.name}_${timestamp}.json`; + + // Create a temporary anchor element and trigger download + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.style.display = 'none'; + + // Append to body, click, and remove + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + // Clean up the URL object + URL.revokeObjectURL(url); + + return { fileName, success: true }; + } catch (error) { + console.error('Error downloading diagram as JSON:', error); + throw new Error('Failed to download diagram file'); + } + } } \ No newline at end of file diff --git a/Website/lib/icons.tsx b/Website/lib/icons.tsx index afb17dc..a7a5fa6 100644 --- a/Website/lib/icons.tsx +++ b/Website/lib/icons.tsx @@ -63,4 +63,16 @@ export const CloudSaveIcon = +; + +export const LocalSaveIcon = + + + + +; + +export const NewIcon = + + ; \ No newline at end of file diff --git a/azure-pipelines-build-jobs.yml b/azure-pipelines-build-jobs.yml index d84eb65..8d5d901 100644 --- a/azure-pipelines-build-jobs.yml +++ b/azure-pipelines-build-jobs.yml @@ -67,21 +67,6 @@ steps: workingDirectory: $(Build.SourcesDirectory)/Website displayName: "Build Next.js app" - - script: | - echo "Cleaning up node_modules before packaging..." - rm -rf "$(Build.SourcesDirectory)/Website/.next/standalone/node_modules" || true - rm -rf "$(Build.SourcesDirectory)/Website/node_modules" || true - - # List contents to verify - echo "Contents of standalone folder:" - ls -la "$(Build.SourcesDirectory)/Website/.next/standalone/" - - # Check the size of the directory - echo "Size of standalone folder:" - du -sh "$(Build.SourcesDirectory)/Website/.next/standalone/" - workingDirectory: $(Build.SourcesDirectory)/Website - displayName: "Clean node_modules" - - task: ArchiveFiles@2 inputs: rootFolderOrFile: "$(Build.SourcesDirectory)/Website/.next/standalone" From ed749ea91d79f264679f811356e13bab21c2ccc6 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 15 Oct 2025 18:39:13 +0200 Subject: [PATCH 13/51] feat: lock cloud buttons if not configured --- .../diagramview/DiagramHeaderToolbar.tsx | 49 +++++++++++++++-- Website/hooks/useDiagramLoad.ts | 21 +------ Website/hooks/useRepositoryInfo.ts | 55 +++++++++++++++++++ 3 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 Website/hooks/useRepositoryInfo.ts diff --git a/Website/components/diagramview/DiagramHeaderToolbar.tsx b/Website/components/diagramview/DiagramHeaderToolbar.tsx index baede8b..996c9a6 100644 --- a/Website/components/diagramview/DiagramHeaderToolbar.tsx +++ b/Website/components/diagramview/DiagramHeaderToolbar.tsx @@ -9,7 +9,8 @@ import { LoadDiagramModal } from './modals/LoadDiagramModal'; import { useDiagramSave } from '@/hooks/useDiagramSave'; import { useDiagramLoad } from '@/hooks/useDiagramLoad'; import { useDiagramView } from '@/contexts/DiagramViewContext'; -import { CheckRounded, ErrorRounded } from '@mui/icons-material'; +import { useRepositoryInfo } from '@/hooks/useRepositoryInfo'; +import { CheckRounded, ErrorRounded, WarningRounded } from '@mui/icons-material'; interface IDiagramHeaderToolbarProps { // No props needed - actions are handled internally @@ -28,6 +29,7 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { openLoadModal, closeLoadModal } = useDiagramLoad(); + const { isCloudConfigured, isLoading: isRepoInfoLoading } = useRepositoryInfo(); const theme = useTheme(); @@ -45,21 +47,21 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { label: 'Save to Cloud', icon: CloudSaveIcon, action: saveDiagramToCloud, - disabled: isSaving || !hasLoadedDiagram || loadedDiagramSource !== 'cloud', + disabled: !isCloudConfigured || isSaving || !hasLoadedDiagram || loadedDiagramSource !== 'cloud', }, { id: 'save-new', label: 'Create in Cloud', icon: CloudNewIcon, action: saveDiagramLocally, - disabled: isSaving + disabled: !isCloudConfigured || isSaving }, { id: 'load', label: 'Load from Cloud', icon: CloudLoadIcon, action: openLoadModal, - disabled: isLoading, + disabled: !isCloudConfigured || isLoading, dividerAfter: true, }, { @@ -84,7 +86,44 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { /> - : } label={hasLoadedDiagram ? 'Diagram Loaded' : 'No Diagram Loaded'} sx={{ backgroundColor: alpha(hasLoadedDiagram ? theme.palette.primary.main : theme.palette.error.main, 0.5) }} /> + + : } + label={hasLoadedDiagram ? 'Diagram Loaded' : 'No Diagram Loaded'} + color="error" + sx={{ + backgroundColor: alpha(hasLoadedDiagram ? theme.palette.primary.main : theme.palette.error.main, 0.5), + '& .MuiChip-icon': { color: hasLoadedDiagram ? theme.palette.primary.contrastText : theme.palette.error.contrastText } + }} + /> + + {!isRepoInfoLoading && !isCloudConfigured && ( + } + label="Cloud Storage Disabled" + color="warning" + sx={{ + backgroundColor: alpha(theme.palette.warning.main, 0.5), + '& .MuiChip-icon': { color: theme.palette.warning.contrastText } + }} + /> + )} + + {!isRepoInfoLoading && isCloudConfigured && ( + } + label="Cloud Storage Ready" + color="success" + sx={{ + backgroundColor: alpha(theme.palette.success.main, 0.5), + '& .MuiChip-icon': { color: theme.palette.success.contrastText } + }} + /> + )} + { setAvailableDiagrams(diagrams); } catch (error) { console.error('Error loading diagram list:', error); - // TODO: Show error notification to user - alert(`Failed to load diagram list: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsLoadingList(false); } @@ -40,20 +38,13 @@ export const useDiagramLoad = () => { setLoadedDiagram, diagramData.name || 'Untitled', 'cloud', - filePath // Pass the filePath for cloud diagrams + filePath ); - console.log('Diagram loaded successfully from cloud:', diagramData.name); - - // TODO: Show success notification to user - alert(`Diagram "${diagramData.name}" loaded successfully`); - setShowLoadModal(false); } catch (error) { console.error('Error loading diagram from cloud:', error); - // TODO: Show error notification to user - alert(`Failed to load diagram: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsLoading(false); } @@ -76,18 +67,10 @@ export const useDiagramLoad = () => { setLoadedDiagram, file.name.replace('.json', ''), 'file', - undefined // No filePath for local files + undefined ); - - console.log('Diagram loaded successfully from file:', diagramData.name); - - // TODO: Show success notification to user - alert(`Diagram "${diagramData.name}" loaded successfully from file`); - } catch (error) { console.error('Error loading diagram from file:', error); - // TODO: Show error notification to user - alert(`Failed to load diagram from file: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsLoading(false); } diff --git a/Website/hooks/useRepositoryInfo.ts b/Website/hooks/useRepositoryInfo.ts new file mode 100644 index 0000000..ade5541 --- /dev/null +++ b/Website/hooks/useRepositoryInfo.ts @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'react'; + +interface RepositoryInfo { + organization: string; + repository: string; + project: string; +} + +interface UseRepositoryInfoResult { + repositoryInfo: RepositoryInfo | null; + isCloudConfigured: boolean; + isLoading: boolean; + error: string | null; +} + +export const useRepositoryInfo = (): UseRepositoryInfoResult => { + const [repositoryInfo, setRepositoryInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRepositoryInfo = async () => { + try { + const response = await fetch('/api/diagram/repository-info'); + if (!response.ok) { + throw new Error('Failed to fetch repository info'); + } + + const data = await response.json(); + setRepositoryInfo(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + setRepositoryInfo(null); + } finally { + setIsLoading(false); + } + }; + + fetchRepositoryInfo(); + }, []); + + const isCloudConfigured = Boolean( + repositoryInfo?.organization && + repositoryInfo?.repository && + repositoryInfo?.project + ); + + return { + repositoryInfo, + isCloudConfigured, + isLoading, + error + }; +}; \ No newline at end of file From 9bb197bc00d40bbd2b56159af9205b8d78c3cd9b Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 15 Oct 2025 18:50:24 +0200 Subject: [PATCH 14/51] chore: diagram information in the sidebar --- .../diagramview/DiagramHeaderToolbar.tsx | 18 +++++++++--------- .../diagramview/SidebarDiagramView.tsx | 16 ++++++++++++++-- .../diagramview/modals/LoadDiagramModal.tsx | 1 + Website/lib/icons.tsx | 10 ++++++---- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/Website/components/diagramview/DiagramHeaderToolbar.tsx b/Website/components/diagramview/DiagramHeaderToolbar.tsx index 996c9a6..6c5e188 100644 --- a/Website/components/diagramview/DiagramHeaderToolbar.tsx +++ b/Website/components/diagramview/DiagramHeaderToolbar.tsx @@ -3,7 +3,7 @@ import React, { use } from 'react'; import { Box, Chip, Typography, useTheme, alpha } from '@mui/material'; import { HeaderDropdownMenu, MenuItemConfig } from './smaller-components/HeaderDropdownMenu'; -import { CloudLoadIcon, CloudNewIcon, CloudSaveIcon, FileMenuIcon, LocalSaveIcon, NewIcon } from '@/lib/icons'; +import { CloudNewIcon, CloudSaveIcon, FileMenuIcon, LoadIcon, LocalSaveIcon, NewIcon } from '@/lib/icons'; import { SaveDiagramModal } from './modals/SaveDiagramModal'; import { LoadDiagramModal } from './modals/LoadDiagramModal'; import { useDiagramSave } from '@/hooks/useDiagramSave'; @@ -40,6 +40,13 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { icon: NewIcon, action: createNewDiagram, disabled: false, + }, + { + id: 'load', + label: 'Load', + icon: LoadIcon, + action: openLoadModal, + disabled: !isCloudConfigured || isLoading, dividerAfter: true, }, { @@ -54,14 +61,7 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { label: 'Create in Cloud', icon: CloudNewIcon, action: saveDiagramLocally, - disabled: !isCloudConfigured || isSaving - }, - { - id: 'load', - label: 'Load from Cloud', - icon: CloudLoadIcon, - action: openLoadModal, - disabled: !isCloudConfigured || isLoading, + disabled: !isCloudConfigured || isSaving, dividerAfter: true, }, { diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index de1e686..e509116 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -2,6 +2,7 @@ import { Box, Tooltip, Typography, Grid } from '@mui/material'; import React, { useState } from 'react'; import { AddSquareIcon } from '@/lib/icons'; import { EntitySelectionPane } from './panes/EntitySelectionPane'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; interface ISidebarDiagramViewProps { @@ -17,6 +18,8 @@ interface DiagramTool { export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { const [entityPaneOpen, setEntityPaneOpen] = useState(false); + const { loadedDiagramFilename, loadedDiagramSource } = useDiagramView(); + const handleAddEntity = () => { setEntityPaneOpen(true); }; @@ -35,7 +38,7 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { ]; return ( - + { - + Elements @@ -72,6 +75,15 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { onClose={handleClosePane} /> + + + + Loaded Diagram: ({loadedDiagramSource}) + + + {loadedDiagramFilename} + + ); } \ No newline at end of file diff --git a/Website/components/diagramview/modals/LoadDiagramModal.tsx b/Website/components/diagramview/modals/LoadDiagramModal.tsx index 1fbc2af..5d27d3d 100644 --- a/Website/components/diagramview/modals/LoadDiagramModal.tsx +++ b/Website/components/diagramview/modals/LoadDiagramModal.tsx @@ -57,6 +57,7 @@ export const LoadDiagramModal = ({ if (fileInputRef.current) { fileInputRef.current.value = ''; } + onClose(); } }; diff --git a/Website/lib/icons.tsx b/Website/lib/icons.tsx index a7a5fa6..e222be4 100644 --- a/Website/lib/icons.tsx +++ b/Website/lib/icons.tsx @@ -50,10 +50,12 @@ export const FileMenuIcon = ; -export const CloudLoadIcon = - - - +export const LoadIcon = + + + + +; export const CloudSaveIcon = From a1db5c40ed1a5a20008ecedfb8f147644a60b7f5 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 15 Oct 2025 19:08:43 +0200 Subject: [PATCH 15/51] chore: implementation of list and rename of some functions --- .../auth/azuredevops/AzureDevOpsService.ts | 83 +++++++++++++++++-- Website/app/api/diagram/list/route.ts | 75 +++++++++-------- Website/app/api/diagram/load/route.ts | 4 +- Website/app/api/diagram/save/route.ts | 7 +- 4 files changed, 126 insertions(+), 43 deletions(-) diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts index d82a867..42ead9c 100644 --- a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts +++ b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts @@ -6,6 +6,7 @@ interface CreateFileOptions { commitMessage?: string; branch?: string; repositoryName?: string; // Optional override + isUpdate?: boolean; // Flag to indicate if this is updating an existing file } interface LoadFileOptions { @@ -14,6 +15,17 @@ interface LoadFileOptions { repositoryName?: string; // Optional override } +interface GitItem { + objectId: string; + gitObjectType: string; + commitId: string; + path: string; + isFolder: boolean; + contentMetadata?: { + size: number; + }; +} + interface GitFileResponse { objectId: string; gitObjectType: string; @@ -72,18 +84,79 @@ async function getRepositoryId(repositoryName?: string): Promise { } } + +/** + * Lists files in the Azure DevOps Git repository + * @param options Configuration for file retrieval + * @returns Promise with array of file items + */ +export async function listFilesFromRepo(options: LoadFileOptions): Promise { + const { + filePath, + branch = 'main', + repositoryName + } = options; + + try { + // Get ADO configuration + const config = managedAuth.getConfig(); + + // Get repository ID from environment variable or parameter + const repositoryId = await getRepositoryId(repositoryName); + + // Construct the API URL for listing items in a folder + const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + const itemsUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryId}/items?scopePath=/${normalizedPath}&version=${branch}&recursionLevel=OneLevel&api-version=7.0`; + + const response = await managedAuth.makeAuthenticatedRequest(itemsUrl); + + if (!response.ok) { + if (response.status === 404) { + throw new AzureDevOpsError(`Folder not found: ${filePath}`, 404); + } + const errorText = await response.text(); + throw new AzureDevOpsError(`Failed to list files: ${response.status} - ${errorText}`, response.status); + } + + const data = await response.json(); + + if (!data.value || !Array.isArray(data.value)) { + return []; + } + + // Filter for files only (not folders) and return as GitItem array + return data.value + .filter((item: any) => !item.isFolder) + .map((item: any) => ({ + objectId: item.objectId, + gitObjectType: item.gitObjectType, + commitId: item.commitId, + path: item.path, + isFolder: item.isFolder, + contentMetadata: item.contentMetadata + })); + + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new AzureDevOpsError(`Unexpected error listing files: ${error instanceof Error ? error.message : String(error)}`); + } +} + /** * Creates a JSON file in the Azure DevOps Git repository * @param options Configuration for file creation * @returns Promise with commit information */ -export async function createFileInRepo(options: CreateFileOptions): Promise { +export async function commitFileToRepo(options: CreateFileOptions): Promise { const { filePath, content, commitMessage = `Add ${filePath}`, branch = 'main', - repositoryName + repositoryName, + isUpdate = false } = options; try { @@ -134,7 +207,7 @@ export async function createFileInRepo(options: CreateFileOptions): Promise(options: LoadFileOptions): Promise { +export async function pullFileFromRepo(options: LoadFileOptions): Promise { const { filePath, branch = 'main', @@ -273,5 +346,5 @@ export async function getRepositoryInfo(repositoryName?: string): Promise<{ id: } // Export types for external use -export type { CreateFileOptions, LoadFileOptions, GitCommitResponse, GitFileResponse }; +export type { CreateFileOptions, LoadFileOptions, GitCommitResponse, GitFileResponse, GitItem }; export { AzureDevOpsError }; \ No newline at end of file diff --git a/Website/app/api/diagram/list/route.ts b/Website/app/api/diagram/list/route.ts index c834e88..890e12e 100644 --- a/Website/app/api/diagram/list/route.ts +++ b/Website/app/api/diagram/list/route.ts @@ -1,46 +1,55 @@ import { NextResponse } from 'next/server'; +import { listFilesFromRepo, type GitItem } from '../../auth/azuredevops/AzureDevOpsService'; + +interface DiagramMetadata { + path: string; + name: string; + createdAt: string; + updatedAt: string; + size: number; +} export async function GET() { try { - // For now, return mock data. In a real implementation, this would - // query the Azure DevOps repository for .json files in the diagrams folder - const mockDiagrams = [ - { - path: 'diagrams/Customer_Management_2025-10-14.json', - name: 'Customer Management', - createdAt: '2025-10-14T10:30:00Z', - updatedAt: '2025-10-14T15:45:00Z', - size: 2048 - }, - { - path: 'diagrams/Order_Processing_2025-10-13.json', - name: 'Order Processing', - createdAt: '2025-10-13T09:15:00Z', - updatedAt: '2025-10-13T16:20:00Z', - size: 1536 - }, - { - path: 'diagrams/User_Authentication_2025-10-12.json', - name: 'User Authentication', - createdAt: '2025-10-12T14:00:00Z', - updatedAt: '2025-10-12T14:30:00Z', - size: 1024 - } - ]; + // List files in the diagrams folder from Azure DevOps + const files = await listFilesFromRepo({ + filePath: 'diagrams', + branch: 'main' + }); + + // Filter for .json files and extract metadata + const diagrams: DiagramMetadata[] = files + .filter((file: GitItem) => file.path.endsWith('.json')) + .map((file: GitItem) => { + // Extract diagram name from filename (remove path and extension) + const fileName = file.path.split('/').pop() || ''; + const nameWithoutExtension = fileName.replace('.json', ''); + + // Try to extract a clean name (remove timestamp if present) + const cleanName = nameWithoutExtension.replace(/_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}.*$/, '').replace(/_/g, ' '); - // TODO: Replace with actual Azure DevOps API call - // This would use the existing AzureDevOpsService to: - // 1. List files in the diagrams/ folder - // 2. Filter for .json files - // 3. Extract metadata (name, dates, size) - // 4. Return formatted list + return { + path: file.path, + name: cleanName || nameWithoutExtension, + createdAt: new Date().toISOString(), // TODO: Get actual creation date from git history + updatedAt: new Date().toISOString(), // TODO: Get actual modification date from git history + size: file.contentMetadata?.size || 0 + }; + }) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); // Sort by most recent first - return NextResponse.json(mockDiagrams); + return NextResponse.json(diagrams); } catch (error) { console.error('Error listing diagrams:', error); + + // If it's a folder not found error, return empty array (diagrams folder doesn't exist yet) + if (error instanceof Error && error.message.includes('Folder not found')) { + return NextResponse.json([]); + } + return NextResponse.json( - { error: 'Failed to list diagrams' }, + { error: `Failed to list diagrams: ${error instanceof Error ? error.message : 'Unknown error'}` }, { status: 500 } ); } diff --git a/Website/app/api/diagram/load/route.ts b/Website/app/api/diagram/load/route.ts index f1dbf0d..a48dca1 100644 --- a/Website/app/api/diagram/load/route.ts +++ b/Website/app/api/diagram/load/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { loadFileFromRepo } from '../../auth/azuredevops/AzureDevOpsService'; +import { pullFileFromRepo } from '../../auth/azuredevops/AzureDevOpsService'; export async function POST(request: NextRequest) { try { @@ -13,7 +13,7 @@ export async function POST(request: NextRequest) { } // Load diagram from Azure DevOps repository - const diagramData = await loadFileFromRepo({ + const diagramData = await pullFileFromRepo({ filePath, branch: 'main' }); diff --git a/Website/app/api/diagram/save/route.ts b/Website/app/api/diagram/save/route.ts index 3ca12c2..e74e5b5 100644 --- a/Website/app/api/diagram/save/route.ts +++ b/Website/app/api/diagram/save/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createFileInRepo } from '../../auth/azuredevops/AzureDevOpsService'; +import { commitFileToRepo } from '../../auth/azuredevops/AzureDevOpsService'; export interface DiagramSaveData { id: string; @@ -72,11 +72,12 @@ export async function POST(request: NextRequest) { ? `Update diagram: ${diagramData.name}` : `Save diagram: ${diagramData.name}`; - const result = await createFileInRepo({ + const result = await commitFileToRepo({ filePath, content: JSON.stringify(enrichedData, null, 2), commitMessage, - branch: 'main' + branch: 'main', + isUpdate: Boolean(diagramData.overwriteFilePath) }); return NextResponse.json({ From d08407b7b59635f8cff684bd2d3c5d192337d5f1 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 15 Oct 2025 19:36:24 +0200 Subject: [PATCH 16/51] chore: update badges and frontpage news --- .../api/auth/azuredevops/AzureDevOpsService.ts | 5 +++-- .../diagramview/DiagramHeaderToolbar.tsx | 4 ++-- .../diagramview/modals/LoadDiagramModal.tsx | 3 --- Website/components/homeview/HomeView.tsx | 16 ++++++++-------- Website/components/shared/Sidebar.tsx | 2 +- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts index 42ead9c..353a25f 100644 --- a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts +++ b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { managedAuth } from './ManagedIdentityAuthService'; interface CreateFileOptions { @@ -126,8 +127,8 @@ export async function listFilesFromRepo(options: LoadFileOptions): Promise !item.isFolder) - .map((item: any) => ({ + .filter(item => !item.isFolder) + .map(item => ({ objectId: item.objectId, gitObjectType: item.gitObjectType, commitId: item.commitId, diff --git a/Website/components/diagramview/DiagramHeaderToolbar.tsx b/Website/components/diagramview/DiagramHeaderToolbar.tsx index 6c5e188..ff693f0 100644 --- a/Website/components/diagramview/DiagramHeaderToolbar.tsx +++ b/Website/components/diagramview/DiagramHeaderToolbar.tsx @@ -1,7 +1,7 @@ 'use client'; -import React, { use } from 'react'; -import { Box, Chip, Typography, useTheme, alpha } from '@mui/material'; +import React from 'react'; +import { Box, Chip, useTheme, alpha } from '@mui/material'; import { HeaderDropdownMenu, MenuItemConfig } from './smaller-components/HeaderDropdownMenu'; import { CloudNewIcon, CloudSaveIcon, FileMenuIcon, LoadIcon, LocalSaveIcon, NewIcon } from '@/lib/icons'; import { SaveDiagramModal } from './modals/SaveDiagramModal'; diff --git a/Website/components/diagramview/modals/LoadDiagramModal.tsx b/Website/components/diagramview/modals/LoadDiagramModal.tsx index 5d27d3d..68e1990 100644 --- a/Website/components/diagramview/modals/LoadDiagramModal.tsx +++ b/Website/components/diagramview/modals/LoadDiagramModal.tsx @@ -20,10 +20,7 @@ import { import { CloudDownload as CloudIcon, Upload as UploadIcon, - Description as FileIcon, Close as CloseIcon, - DocumentScannerRounded, - FolderRounded, PolylineRounded } from '@mui/icons-material'; import { DiagramFile } from '@/lib/diagram/services/diagram-deserialization'; diff --git a/Website/components/homeview/HomeView.tsx b/Website/components/homeview/HomeView.tsx index 052f6a5..b7d47d0 100644 --- a/Website/components/homeview/HomeView.tsx +++ b/Website/components/homeview/HomeView.tsx @@ -23,6 +23,14 @@ export const HomeView = ({ }: IHomeViewProps) => { // Carousel data const carouselItems: CarouselItem[] = [ + { + image: '/documentation.jpg', + title: 'Connect to your Azure DevOps!', + text: 'The diagram tool is the first to take advantage of the new integration. Save and load your diagrams directly from your Azure DevOps repository to keep version control on your diagrams. Check out the documentation to get started.', + type: '(v2.2.0) Feature', + actionlabel: 'Go to Diagrams', + action: () => router.push('/diagram') + }, { image: '/insights.jpg', title: 'Insights are here!', @@ -44,14 +52,6 @@ export const HomeView = ({ }: IHomeViewProps) => { title: 'Data Model Viewer 2.0.0!', text: "The UI has been refreshed for an even cleaner, more modern look with enhanced functionality. And we've upgraded the tech stack to ensure easier maintainability.", type: '(v2.0.0) Announcement' - }, - { - image: '/documentation.jpg', - title: 'Home WIKI ADO Page', - text: 'Display your own wiki page from your ADO instance. Use it, to give your organisation a special introduction to DMV. Now also supports images!', - type: '(v1.4.1) Feature', - actionlabel: 'Read how', - action: () => window.open("https://github.com/delegateas/DataModelViewer", '_blank') } ]; diff --git a/Website/components/shared/Sidebar.tsx b/Website/components/shared/Sidebar.tsx index cf440dd..04298cf 100644 --- a/Website/components/shared/Sidebar.tsx +++ b/Website/components/shared/Sidebar.tsx @@ -41,7 +41,6 @@ const Sidebar = ({ }: SidebarProps) => { href: '/insights', icon: InsightsIcon, active: pathname === '/insights', - new: true, }, { label: 'Metadata', @@ -54,6 +53,7 @@ const Sidebar = ({ }: SidebarProps) => { href: '/diagram', icon: DiagramIcon, active: pathname === '/diagram', + new: true, }, { label: 'Processes', From cc741081ac0208684f4e346c0e815bf2e053f6d1 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 15 Oct 2025 19:40:47 +0200 Subject: [PATCH 17/51] fix: build error fix --- Website/app/api/auth/azuredevops/AzureDevOpsService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts index 353a25f..f285643 100644 --- a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts +++ b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts @@ -127,8 +127,8 @@ export async function listFilesFromRepo(options: LoadFileOptions): Promise !item.isFolder) - .map(item => ({ + .filter((item: any) => !item.isFolder) + .map((item: any) => ({ objectId: item.objectId, gitObjectType: item.gitObjectType, commitId: item.commitId, From 55f555e2bdb18cf0dcc7fd6bc2790f5b550de478 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 15 Oct 2025 20:18:03 +0200 Subject: [PATCH 18/51] fix: replaced some queryparams for the load --- Website/app/api/auth/azuredevops/AzureDevOpsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts index f285643..11fee3b 100644 --- a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts +++ b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts @@ -277,7 +277,7 @@ export async function pullFileFromRepo(options: LoadFileOptions): Promise // Construct the API URL for getting file content const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; - const fileUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryId}/items?path=/${normalizedPath}&version=${branch}&includeContent=true&api-version=7.0`; + const fileUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryId}/items?path=/${normalizedPath}&versionDescriptor.version=${branch}&versionDescriptor.versionType=branch&includeContent=true&api-version=7.0`; const response = await managedAuth.makeAuthenticatedRequest(fileUrl); From 743964dc0f354b129e53124a377e4c3381a962fa Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 15 Oct 2025 20:31:23 +0200 Subject: [PATCH 19/51] fix: inconsistent envvariable naming. --- Infrastructure/main.bicep | 2 +- Website/app/api/auth/azuredevops/AzureDevOpsService.ts | 2 +- Website/app/api/diagram/repository-info/route.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Infrastructure/main.bicep b/Infrastructure/main.bicep index b2e55ac..c3fa9ac 100644 --- a/Infrastructure/main.bicep +++ b/Infrastructure/main.bicep @@ -58,7 +58,7 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = { value: adoProjectName } { - name: 'AdoRepositoryName' + name: 'ADO_REPOSITORY_NAME' value: adoRepositoryName } ] diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts index 11fee3b..57402fe 100644 --- a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts +++ b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts @@ -292,7 +292,7 @@ export async function pullFileFromRepo(options: LoadFileOptions): Promise const fileData: GitFileResponse = await response.json(); if (!fileData.content) { - throw new AzureDevOpsError(`File content is empty: ${filePath}`); + throw new AzureDevOpsError(`... File content is empty: ${fileUrl}`); } // Decode base64 content and parse JSON diff --git a/Website/app/api/diagram/repository-info/route.ts b/Website/app/api/diagram/repository-info/route.ts index 1f290ea..7834589 100644 --- a/Website/app/api/diagram/repository-info/route.ts +++ b/Website/app/api/diagram/repository-info/route.ts @@ -4,7 +4,7 @@ export async function GET() { try { // Extract organization name from the URL const organizationUrl = process.env.ADO_ORGANIZATION_URL || ''; - const repositoryName = process.env.AdoRepositoryName || ''; + const repositoryName = process.env.ADO_REPOSITORY_NAME || ''; // Parse organization name from URL (e.g., "https://dev.azure.com/MedlemX/" -> "MedlemX") let organizationName = ''; From 8db109db0ff1e03d85489071c4bee5952f3c30fa Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 15 Oct 2025 22:08:31 +0200 Subject: [PATCH 20/51] feat: local development using PAT and readme on it --- Website/README.md | 15 ++++++ .../auth/azuredevops/AzureDevOpsService.ts | 53 +++---------------- .../azuredevops/ManagedIdentityAuthService.ts | 27 ++++++++-- Website/app/api/diagram/list/route.ts | 3 +- Website/app/api/diagram/load/route.ts | 3 +- Website/app/api/diagram/save/route.ts | 1 + 6 files changed, 50 insertions(+), 52 deletions(-) create mode 100644 Website/README.md diff --git a/Website/README.md b/Website/README.md new file mode 100644 index 0000000..96e1d90 --- /dev/null +++ b/Website/README.md @@ -0,0 +1,15 @@ +# Local developer setup +Create local file `.env.local`. Fill variables: +WebsitePassword= +WebsiteSessionSecret= +ADO_PROJECT_NAME= +ADO_ORGANIZATION_URL= +ADO_REPOSITORY_NAME= +AZURE_CLI_AUTHENTICATION_ENABLED=true +ADO_PAT= + +### Node +Run `npm i` + +### Authentication to Dev Ops +Go to a DevOps instance and create a PAT token for at least read/write to repos \ No newline at end of file diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts index 57402fe..25686d9 100644 --- a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts +++ b/Website/app/api/auth/azuredevops/AzureDevOpsService.ts @@ -57,35 +57,6 @@ class AzureDevOpsError extends Error { } } -async function getRepositoryId(repositoryName?: string): Promise { - const config = managedAuth.getConfig(); - const repoName = repositoryName || process.env.AdoRepositoryName || config.repositoryName; - - if (!repoName) { - throw new AzureDevOpsError('Repository name not found. Set AdoRepositoryName environment variable or pass repositoryName parameter.'); - } - - try { - const repoUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${encodeURIComponent(repoName)}?api-version=7.0`; - const response = await managedAuth.makeAuthenticatedRequest(repoUrl); - - if (!response.ok) { - const errorText = await response.text(); - throw new AzureDevOpsError(`Failed to get repository info for '${repoName}': ${response.status} - ${errorText}`, response.status); - } - - const repoData = await response.json(); - return repoData.id; - - } catch (error) { - if (error instanceof AzureDevOpsError) { - throw error; - } - throw new AzureDevOpsError(`Unexpected error getting repository ID for '${repoName}': ${error instanceof Error ? error.message : String(error)}`); - } -} - - /** * Lists files in the Azure DevOps Git repository * @param options Configuration for file retrieval @@ -102,12 +73,9 @@ export async function listFilesFromRepo(options: LoadFileOptions): Promise(options: LoadFileOptions): Promise throw new AzureDevOpsError('File path is required'); } - // Get repository ID from environment variable or parameter - const repositoryId = await getRepositoryId(repositoryName); - // Construct the API URL for getting file content const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; - const fileUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryId}/items?path=/${normalizedPath}&versionDescriptor.version=${branch}&versionDescriptor.versionType=branch&includeContent=true&api-version=7.0`; + const fileUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/items?path=/${normalizedPath}&versionDescriptor.version=${branch}&versionDescriptor.versionType=branch&includeContent=true&api-version=7.0`; const response = await managedAuth.makeAuthenticatedRequest(fileUrl); @@ -292,14 +254,11 @@ export async function pullFileFromRepo(options: LoadFileOptions): Promise const fileData: GitFileResponse = await response.json(); if (!fileData.content) { - throw new AzureDevOpsError(`... File content is empty: ${fileUrl}`); + throw new AzureDevOpsError(`File content is empty: ${fileUrl}`); } - - // Decode base64 content and parse JSON - const decodedContent = Buffer.from(fileData.content, 'base64').toString('utf-8'); try { - return JSON.parse(decodedContent) as T; + return JSON.parse(fileData.content) as T; } catch (parseError) { throw new AzureDevOpsError(`Failed to parse JSON content: ${parseError instanceof Error ? parseError.message : String(parseError)}`); } diff --git a/Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts b/Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts index d8e3846..daaddb4 100644 --- a/Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts +++ b/Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts @@ -47,14 +47,35 @@ class ManagedIdentityAuth { } async makeAuthenticatedRequest(url: string, options: RequestInit = {}): Promise { - const token = await this.getAccessToken(); + // Use PAT for local development, Managed Identity for production + const pat = process.env.ADO_PAT; + const isLocal = process.env.NODE_ENV === 'development' || pat; + + let authHeaders: Record; + + if (isLocal && pat) { + console.log('Using PAT authentication for local development'); + const basic = Buffer.from(`:${pat}`).toString('base64'); + authHeaders = { + 'Authorization': `Basic ${basic}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-TFS-FedAuthRedirect': 'Suppress' + }; + } else { + const token = await this.getAccessToken(); + authHeaders = { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + } return fetch(url, { ...options, headers: { ...options.headers, - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' + ...authHeaders } }); } diff --git a/Website/app/api/diagram/list/route.ts b/Website/app/api/diagram/list/route.ts index 890e12e..2c733e1 100644 --- a/Website/app/api/diagram/list/route.ts +++ b/Website/app/api/diagram/list/route.ts @@ -14,7 +14,8 @@ export async function GET() { // List files in the diagrams folder from Azure DevOps const files = await listFilesFromRepo({ filePath: 'diagrams', - branch: 'main' + branch: 'main', + repositoryName: process.env.ADO_REPOSITORY_NAME || '' }); // Filter for .json files and extract metadata diff --git a/Website/app/api/diagram/load/route.ts b/Website/app/api/diagram/load/route.ts index a48dca1..d3f7252 100644 --- a/Website/app/api/diagram/load/route.ts +++ b/Website/app/api/diagram/load/route.ts @@ -15,7 +15,8 @@ export async function POST(request: NextRequest) { // Load diagram from Azure DevOps repository const diagramData = await pullFileFromRepo({ filePath, - branch: 'main' + branch: 'main', + repositoryName: process.env.ADO_REPOSITORY_NAME || '' }); return NextResponse.json(diagramData); diff --git a/Website/app/api/diagram/save/route.ts b/Website/app/api/diagram/save/route.ts index e74e5b5..03895ea 100644 --- a/Website/app/api/diagram/save/route.ts +++ b/Website/app/api/diagram/save/route.ts @@ -77,6 +77,7 @@ export async function POST(request: NextRequest) { content: JSON.stringify(enrichedData, null, 2), commitMessage, branch: 'main', + repositoryName: process.env.ADO_REPOSITORY_NAME || '', isUpdate: Boolean(diagramData.overwriteFilePath) }); From bfea9e1734ee99152b869f2687512003e8a33fec Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Thu, 16 Oct 2025 18:09:02 +0200 Subject: [PATCH 21/51] feat: diagramname textfield & graph state loading (zoom, pan etc) --- Website/app/api/diagram/save/route.ts | 22 ++------- .../diagramview/DiagramHeaderToolbar.tsx | 2 +- .../diagramview/SidebarDiagramView.tsx | 24 ++++++---- Website/contexts/DiagramViewContext.tsx | 47 +++++++++++++++++-- Website/hooks/useDiagramLoad.ts | 8 ++-- Website/hooks/useDiagramSave.ts | 8 ++-- .../services/diagram-deserialization.ts | 25 ++-------- .../diagram/services/diagram-serialization.ts | 5 +- 8 files changed, 74 insertions(+), 67 deletions(-) diff --git a/Website/app/api/diagram/save/route.ts b/Website/app/api/diagram/save/route.ts index 03895ea..299f794 100644 --- a/Website/app/api/diagram/save/route.ts +++ b/Website/app/api/diagram/save/route.ts @@ -1,25 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { commitFileToRepo } from '../../auth/azuredevops/AzureDevOpsService'; +import { SerializedDiagram } from '@/lib/diagram/models/serialized-diagram'; -export interface DiagramSaveData { - id: string; - name: string; - version: string; - createdAt: string; - updatedAt: string; - overwriteFilePath?: string; // Optional path for overwriting existing files - metadata: { - zoom: number; - translate: { x: number; y: number }; - canvasSize?: { width: number; height: number }; - }; - entities: Array<{ - id: string; - type: string; - position: { x: number; y: number }; - size: { width: number; height: number }; - label: string; - }>; +interface DiagramSaveData extends SerializedDiagram { + overwriteFilePath?: string; } export async function POST(request: NextRequest) { diff --git a/Website/components/diagramview/DiagramHeaderToolbar.tsx b/Website/components/diagramview/DiagramHeaderToolbar.tsx index ff693f0..1f4250a 100644 --- a/Website/components/diagramview/DiagramHeaderToolbar.tsx +++ b/Website/components/diagramview/DiagramHeaderToolbar.tsx @@ -60,7 +60,7 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { id: 'save-new', label: 'Create in Cloud', icon: CloudNewIcon, - action: saveDiagramLocally, + action: saveDiagramToCloud, disabled: !isCloudConfigured || isSaving, dividerAfter: true, }, diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index e509116..ac4c1f0 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -1,4 +1,4 @@ -import { Box, Tooltip, Typography, Grid } from '@mui/material'; +import { Box, Tooltip, Typography, Grid, TextField } from '@mui/material'; import React, { useState } from 'react'; import { AddSquareIcon } from '@/lib/icons'; import { EntitySelectionPane } from './panes/EntitySelectionPane'; @@ -18,7 +18,7 @@ interface DiagramTool { export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { const [entityPaneOpen, setEntityPaneOpen] = useState(false); - const { loadedDiagramFilename, loadedDiagramSource } = useDiagramView(); + const { loadedDiagramFilename, loadedDiagramSource, hasLoadedDiagram, diagramName, setDiagramName } = useDiagramView(); const handleAddEntity = () => { setEntityPaneOpen(true); @@ -28,6 +28,10 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { setEntityPaneOpen(false); }; + const handleDiagramNameChange = (event: React.ChangeEvent) => { + setDiagramName(event.target.value); + }; + const diagramTools: DiagramTool[] = [ { id: 'add-entity', @@ -40,13 +44,15 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { return ( - - Diagram Tools - + diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index b940bd2..f949e7b 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -7,8 +7,11 @@ interface DiagramActions { setTranslate: (translate: { x: number; y: number }) => void; addEntity: (position?: { x: number; y: number }, label?: string) => void; getGraph: () => dia.Graph | null; + getPaper: () => dia.Paper | null; + applyZoomAndPan: (zoom: number, translate: { x: number; y: number }) => void; setLoadedDiagram: (filename: string | null, source: 'cloud' | 'file' | null, filePath?: string | null) => void; clearDiagram: () => void; + setDiagramName: (name: string) => void; } export interface DiagramState extends DiagramActions { @@ -20,6 +23,7 @@ export interface DiagramState extends DiagramActions { loadedDiagramSource: 'cloud' | 'file' | null; loadedDiagramFilePath: string | null; hasLoadedDiagram: boolean; + diagramName: string; } const initialState: DiagramState = { @@ -31,14 +35,18 @@ const initialState: DiagramState = { loadedDiagramSource: null, loadedDiagramFilePath: null, hasLoadedDiagram: false, + diagramName: 'untitled', setZoom: () => { throw new Error("setZoom not initialized yet!"); }, setIsPanning: () => { throw new Error("setIsPanning not initialized yet!"); }, setTranslate: () => { throw new Error("setTranslate not initialized yet!"); }, addEntity: () => { throw new Error("addEntity not initialized yet!"); }, getGraph: () => { throw new Error("getGraph not initialized yet!"); }, + getPaper: () => { throw new Error("getPaper not initialized yet!"); }, + applyZoomAndPan: () => { throw new Error("applyZoomAndPan not initialized yet!"); }, setLoadedDiagram: () => { throw new Error("setLoadedDiagram not initialized yet!"); }, clearDiagram: () => { throw new Error("clearDiagram not initialized yet!"); }, + setDiagramName: () => { throw new Error("setDiagramName not initialized yet!"); }, } type DiagramViewAction = @@ -46,7 +54,8 @@ type DiagramViewAction = | { type: 'SET_IS_PANNING', payload: boolean } | { type: 'SET_TRANSLATE', payload: { x: number; y: number } } | { type: 'SET_LOADED_DIAGRAM', payload: { filename: string | null; source: 'cloud' | 'file' | null; filePath?: string | null } } - | { type: 'CLEAR_DIAGRAM' }; + | { type: 'CLEAR_DIAGRAM' } + | { type: 'SET_DIAGRAM_NAME', payload: string }; const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): DiagramState => { switch (action.type) { @@ -62,7 +71,8 @@ const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): Dia loadedDiagramFilename: action.payload.filename, loadedDiagramSource: action.payload.source, loadedDiagramFilePath: action.payload.filePath || null, - hasLoadedDiagram: action.payload.filename !== null + hasLoadedDiagram: action.payload.filename !== null, + diagramName: action.payload.filename || 'untitled' } case 'CLEAR_DIAGRAM': return { @@ -70,8 +80,11 @@ const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): Dia loadedDiagramFilename: null, loadedDiagramSource: null, loadedDiagramFilePath: null, - hasLoadedDiagram: false + hasLoadedDiagram: false, + diagramName: 'untitled' } + case 'SET_DIAGRAM_NAME': + return { ...state, diagramName: action.payload } default: return state; } @@ -107,6 +120,10 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { dispatch({ type: 'CLEAR_DIAGRAM' }); } + const setDiagramName = (name: string) => { + dispatch({ type: 'SET_DIAGRAM_NAME', payload: name }); + } + // Refs to store graph and paper instances const graphRef = useRef(null); const paperRef = useRef(null); @@ -355,8 +372,30 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { return graphRef.current; }; + const getPaper = () => { + return paperRef.current; + }; + + const applyZoomAndPan = (zoom: number, translate: { x: number; y: number }) => { + if (paperRef.current) { + // Apply the transform matrix to the paper + paperRef.current.matrix({ + a: zoom, + b: 0, + c: 0, + d: zoom, + e: translate.x, + f: translate.y + }); + + // Update the context state + setZoom(zoom); + setTranslate(translate); + } + }; + return ( - + {children} diff --git a/Website/hooks/useDiagramLoad.ts b/Website/hooks/useDiagramLoad.ts index b5cb21b..47d813e 100644 --- a/Website/hooks/useDiagramLoad.ts +++ b/Website/hooks/useDiagramLoad.ts @@ -3,7 +3,7 @@ import { useDiagramView } from '@/contexts/DiagramViewContext'; import { DiagramDeserializationService, DiagramFile } from '@/lib/diagram/services/diagram-deserialization'; export const useDiagramLoad = () => { - const { getGraph, setZoom, setTranslate, setLoadedDiagram } = useDiagramView(); + const { getGraph, applyZoomAndPan, setLoadedDiagram } = useDiagramView(); const [isLoading, setIsLoading] = useState(false); const [showLoadModal, setShowLoadModal] = useState(false); const [availableDiagrams, setAvailableDiagrams] = useState([]); @@ -33,8 +33,7 @@ export const useDiagramLoad = () => { DiagramDeserializationService.deserializeDiagram( diagramData, graph, - setZoom, - setTranslate, + applyZoomAndPan, setLoadedDiagram, diagramData.name || 'Untitled', 'cloud', @@ -62,8 +61,7 @@ export const useDiagramLoad = () => { DiagramDeserializationService.deserializeDiagram( diagramData, graph, - setZoom, - setTranslate, + applyZoomAndPan, setLoadedDiagram, file.name.replace('.json', ''), 'file', diff --git a/Website/hooks/useDiagramSave.ts b/Website/hooks/useDiagramSave.ts index cf0471e..6dfdf01 100644 --- a/Website/hooks/useDiagramSave.ts +++ b/Website/hooks/useDiagramSave.ts @@ -3,7 +3,7 @@ import { useDiagramView } from '@/contexts/DiagramViewContext'; import { DiagramSerializationService } from '@/lib/diagram/services/diagram-serialization'; export const useDiagramSave = () => { - const { getGraph, zoom, translate, clearDiagram, setLoadedDiagram, loadedDiagramFilename, loadedDiagramSource, loadedDiagramFilePath } = useDiagramView(); + const { getGraph, zoom, translate, clearDiagram, setLoadedDiagram, loadedDiagramFilename, loadedDiagramSource, loadedDiagramFilePath, diagramName } = useDiagramView(); const [isSaving, setIsSaving] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false); @@ -15,7 +15,7 @@ export const useDiagramSave = () => { try { const graph = getGraph(); - const diagramData = DiagramSerializationService.serializeDiagram(graph, zoom, translate); + const diagramData = DiagramSerializationService.serializeDiagram(graph, zoom, translate, diagramName); // If we have a loaded diagram from cloud, preserve its name for overwriting if (loadedDiagramSource === 'cloud' && loadedDiagramFilename) { @@ -29,8 +29,6 @@ export const useDiagramSave = () => { // Track that this diagram is now loaded from cloud with the correct file path const resultFilePath = result.filePath || overwriteFilePath; setLoadedDiagram(diagramData.name, 'cloud', resultFilePath); - - console.log('Diagram saved to cloud successfully:', result); } catch (error) { console.error('Error saving diagram to cloud:', error); } finally { @@ -46,7 +44,7 @@ export const useDiagramSave = () => { try { const graph = getGraph(); - const diagramData = DiagramSerializationService.serializeDiagram(graph, zoom, translate); + const diagramData = DiagramSerializationService.serializeDiagram(graph, zoom, translate, diagramName); const downloadResult = DiagramSerializationService.downloadDiagramAsJson(diagramData); // Track that this diagram is now loaded as a file diff --git a/Website/lib/diagram/services/diagram-deserialization.ts b/Website/lib/diagram/services/diagram-deserialization.ts index 69385c0..0618763 100644 --- a/Website/lib/diagram/services/diagram-deserialization.ts +++ b/Website/lib/diagram/services/diagram-deserialization.ts @@ -42,8 +42,7 @@ export class DiagramDeserializationService { static deserializeDiagram( diagramData: SerializedDiagram, graph: dia.Graph | null, - setZoom: (zoom: number) => void, - setTranslate: (translate: { x: number; y: number }) => void, + applyZoomAndPan: (zoom: number, translate: { x: number; y: number }) => void, setLoadedDiagram: (filename: string | null, source: 'cloud' | 'file' | null, filePath?: string | null) => void, filename: string, source: 'cloud' | 'file', @@ -56,29 +55,14 @@ export class DiagramDeserializationService { // Clear existing diagram graph.clear(); - // Restore zoom and pan - setZoom(diagramData.metadata.zoom); - setTranslate(diagramData.metadata.translate); + // Restore zoom and pan - apply directly to the paper + applyZoomAndPan(diagramData.metadata.zoom, diagramData.metadata.translate); // Set loaded diagram info setLoadedDiagram(filename, source, filePath); - // Theme-aware entity colors using MUI CSS variables (same as addEntity) - const colors = [ - { fill: 'var(--mui-palette-primary-main)', stroke: 'var(--mui-palette-primary-dark)' }, - { fill: 'var(--mui-palette-success-main)', stroke: 'var(--mui-palette-success-dark)' }, - { fill: 'var(--mui-palette-warning-main)', stroke: 'var(--mui-palette-warning-dark)' }, - { fill: 'var(--mui-palette-error-main)', stroke: 'var(--mui-palette-error-dark)' }, - { fill: 'var(--mui-palette-secondary-main)', stroke: 'var(--mui-palette-secondary-dark)' }, - { fill: 'var(--mui-palette-info-main)', stroke: 'var(--mui-palette-info-dark)' }, - ]; - - const textColor = 'var(--mui-palette-primary-contrastText)'; - // Recreate entities diagramData.entities.forEach((entityData: SerializedEntity, index: number) => { - const colorIndex = index % colors.length; - const color = colors[colorIndex]; const rect = new shapes.standard.Rectangle({ id: entityData.id, @@ -86,15 +70,12 @@ export class DiagramDeserializationService { size: entityData.size, attrs: { body: { - fill: color.fill, - stroke: color.stroke, strokeWidth: 2, rx: 8, ry: 8 }, label: { text: entityData.label, - fill: textColor, fontSize: 14, fontFamily: 'Arial, sans-serif' } diff --git a/Website/lib/diagram/services/diagram-serialization.ts b/Website/lib/diagram/services/diagram-serialization.ts index 114c381..31d6fbb 100644 --- a/Website/lib/diagram/services/diagram-serialization.ts +++ b/Website/lib/diagram/services/diagram-serialization.ts @@ -6,7 +6,8 @@ export class DiagramSerializationService { static serializeDiagram( graph: dia.Graph | null, zoom: number, - translate: { x: number; y: number } + translate: { x: number; y: number }, + diagramName: string ): SerializedDiagram { if (!graph) { throw new Error('No diagram graph available'); @@ -35,7 +36,7 @@ export class DiagramSerializationService { return { id: crypto.randomUUID(), - name: `Diagram_${new Date().toISOString().split('T')[0]}`, + name: diagramName, version: '1.0.0', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), From bcb0c4fb0e90c22e607cc6e80f0aefd17952cdb5 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Thu, 16 Oct 2025 18:36:43 +0200 Subject: [PATCH 22/51] chore: adjustments to the load modal (azure call moved to seperated step) --- .../diagramview/DiagramHeaderToolbar.tsx | 6 +- .../diagramview/modals/LoadDiagramModal.tsx | 100 ++++++++++++------ .../shared/elements/ClickableCard.tsx | 76 +++++++++++++ Website/hooks/useDiagramLoad.ts | 2 +- Website/lib/icons.tsx | 4 + 5 files changed, 151 insertions(+), 37 deletions(-) create mode 100644 Website/components/shared/elements/ClickableCard.tsx diff --git a/Website/components/diagramview/DiagramHeaderToolbar.tsx b/Website/components/diagramview/DiagramHeaderToolbar.tsx index 1f4250a..8201e6d 100644 --- a/Website/components/diagramview/DiagramHeaderToolbar.tsx +++ b/Website/components/diagramview/DiagramHeaderToolbar.tsx @@ -25,7 +25,8 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { showLoadModal, availableDiagrams, loadDiagramFromCloud, - loadDiagramFromFile, + loadDiagramFromFile, + loadAvailableDiagrams, openLoadModal, closeLoadModal } = useDiagramLoad(); @@ -46,7 +47,7 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { label: 'Load', icon: LoadIcon, action: openLoadModal, - disabled: !isCloudConfigured || isLoading, + disabled: isLoading, dividerAfter: true, }, { @@ -139,6 +140,7 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { isLoading={isLoading} onLoadFromCloud={loadDiagramFromCloud} onLoadFromFile={loadDiagramFromFile} + onLoadAvailableDiagrams={loadAvailableDiagrams} /> ); diff --git a/Website/components/diagramview/modals/LoadDiagramModal.tsx b/Website/components/diagramview/modals/LoadDiagramModal.tsx index 68e1990..6192a59 100644 --- a/Website/components/diagramview/modals/LoadDiagramModal.tsx +++ b/Website/components/diagramview/modals/LoadDiagramModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import { Dialog, DialogContent, @@ -12,8 +12,6 @@ import { ListItemButton, ListItemText, ListItemIcon, - Divider, - Button, CircularProgress, IconButton } from '@mui/material'; @@ -21,9 +19,14 @@ import { CloudDownload as CloudIcon, Upload as UploadIcon, Close as CloseIcon, - PolylineRounded + PolylineRounded, + ArrowBack as ArrowBackIcon } from '@mui/icons-material'; import { DiagramFile } from '@/lib/diagram/services/diagram-deserialization'; +import { ClickableCard } from '@/components/shared/elements/ClickableCard'; +import { AzureDevOpsIcon, LoadIcon } from '@/lib/icons'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; +import { useRepositoryInfo } from '@/hooks/useRepositoryInfo'; interface LoadDiagramModalProps { open: boolean; @@ -33,6 +36,7 @@ interface LoadDiagramModalProps { isLoading: boolean; onLoadFromCloud: (filePath: string) => void; onLoadFromFile: (file: File) => void; + onLoadAvailableDiagrams: () => void; } export const LoadDiagramModal = ({ @@ -42,9 +46,12 @@ export const LoadDiagramModal = ({ isLoadingList, isLoading, onLoadFromCloud, - onLoadFromFile + onLoadFromFile, + onLoadAvailableDiagrams }: LoadDiagramModalProps) => { const fileInputRef = useRef(null); + const [showCloudDiagrams, setShowCloudDiagrams] = useState(false); + const { isCloudConfigured } = useRepositoryInfo(); const handleFileUpload = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -62,6 +69,20 @@ export const LoadDiagramModal = ({ fileInputRef.current?.click(); }; + const handleCloudClick = () => { + setShowCloudDiagrams(true); + onLoadAvailableDiagrams(); + }; + + const handleBackToOptions = () => { + setShowCloudDiagrams(false); + }; + + const handleCloseModal = () => { + setShowCloudDiagrams(false); + onClose(); + }; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', @@ -80,8 +101,8 @@ export const LoadDiagramModal = ({ return ( - Load Diagram - + + {showCloudDiagrams && ( + + + + )} + + {showCloudDiagrams ? 'Select from Azure DevOps' : 'Load Diagram'} + + + - - {/* Upload from file section */} - - - Load from File - - } + {!showCloudDiagrams ? ( + // Main options view + + {/* Load from Device Card */} + - Choose local File... - + color="primary.main" + /> + + {/* Load from Azure DevOps Card */} + + + {/* Hidden file input */} - - - - {/* Cloud diagrams section */} + ) : ( + // Cloud diagrams list view - - Load from Cloud - - {isLoadingList ? ( @@ -150,7 +182,7 @@ export const LoadDiagramModal = ({ ) : ( - + {availableDiagrams.map((diagram) => ( )} - + )} ); diff --git a/Website/components/shared/elements/ClickableCard.tsx b/Website/components/shared/elements/ClickableCard.tsx new file mode 100644 index 0000000..36d5c20 --- /dev/null +++ b/Website/components/shared/elements/ClickableCard.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React from 'react'; +import { + Card, + CardContent, + CardActionArea, + Box, + Typography +} from '@mui/material'; + +interface ClickableCardProps { + title: string; + description: string; + icon: React.ReactNode; + onClick: () => void; + disabled?: boolean; + color?: string; +} + +export const ClickableCard = ({ + title, + description, + icon, + onClick, + disabled = false, + color = '#1976d2' // Default primary blue +}: ClickableCardProps) => { + return ( + + + + + + {icon} + + + + + + {title} + + + {description} + + + + + + ); +}; \ No newline at end of file diff --git a/Website/hooks/useDiagramLoad.ts b/Website/hooks/useDiagramLoad.ts index 47d813e..f60a98e 100644 --- a/Website/hooks/useDiagramLoad.ts +++ b/Website/hooks/useDiagramLoad.ts @@ -76,7 +76,6 @@ export const useDiagramLoad = () => { const openLoadModal = () => { setShowLoadModal(true); - loadAvailableDiagrams(); }; const closeLoadModal = () => { @@ -90,6 +89,7 @@ export const useDiagramLoad = () => { availableDiagrams, loadDiagramFromCloud, loadDiagramFromFile, + loadAvailableDiagrams, openLoadModal, closeLoadModal }; diff --git a/Website/lib/icons.tsx b/Website/lib/icons.tsx index e222be4..ae1d04f 100644 --- a/Website/lib/icons.tsx +++ b/Website/lib/icons.tsx @@ -77,4 +77,8 @@ export const LocalSaveIcon = +; + +export const AzureDevOpsIcon = + ; \ No newline at end of file From 1ac388174741d8c8c267f5da6554f92a34e4d1c0 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Thu, 16 Oct 2025 19:45:21 +0200 Subject: [PATCH 23/51] chore: refactored the group/entity accordion to seperate element and reused for the entity picker pane --- .../datamodelview/SidebarDatamodelView.tsx | 200 +++-------------- .../diagramview/SidebarDiagramView.tsx | 6 +- .../diagramview/panes/EntitySelectionPane.tsx | 129 +++-------- .../shared/elements/EntityGroupAccordion.tsx | 210 ++++++++++++++++++ 4 files changed, 274 insertions(+), 271 deletions(-) create mode 100644 Website/components/shared/elements/EntityGroupAccordion.tsx diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index bfe8e6e..1b8f8d8 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -1,28 +1,23 @@ import { EntityType, GroupType } from "@/lib/Types"; import { useSidebar } from '@/contexts/SidebarContext'; import { cn } from "@/lib/utils"; -import { Accordion, AccordionSummary, AccordionDetails, Box, InputAdornment, Paper, Typography, Button, CircularProgress } from '@mui/material'; -import { ExpandMore, ExtensionRounded, OpenInNewRounded, SearchRounded } from '@mui/icons-material'; +import { Box, InputAdornment, Paper, Typography, CircularProgress } from '@mui/material'; +import { SearchRounded } from '@mui/icons-material'; import { useState, useEffect, useMemo, useCallback } from "react"; import { TextField } from "@mui/material"; import { useDatamodelView, useDatamodelViewDispatch } from "@/contexts/DatamodelViewContext"; import { useDatamodelData } from "@/contexts/DatamodelDataContext"; import { useIsMobile } from "@/hooks/use-mobile"; -import { useTheme, alpha } from '@mui/material/styles'; +import { EntityGroupAccordion } from "@/components/shared/elements/EntityGroupAccordion"; interface ISidebarDatamodelViewProps { } -interface INavItemProps { - group: GroupType, -} - export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { const { currentSection, currentGroup, scrollToSection, scrollToGroup, loadingSection } = useDatamodelView(); const { close: closeSidebar } = useSidebar(); - const theme = useTheme(); const isMobile = useIsMobile(); const dataModelDispatch = useDatamodelViewDispatch(); @@ -78,12 +73,6 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { setDisplaySearchTerm(""); }, []); - const isEntityMatch = useCallback((entity: EntityType) => { - if (!searchTerm.trim()) return false; - return entity.SchemaName.toLowerCase().includes(searchTerm.toLowerCase()) || - entity.DisplayName.toLowerCase().includes(searchTerm.toLowerCase()); - }, [searchTerm]); - const highlightText = useCallback((text: string, searchTerm: string) => { if (!searchTerm.trim()) return text; const regex = new RegExp(`(${searchTerm})`, 'gi'); @@ -106,10 +95,9 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { } return newExpanded; }); - }, [dataModelDispatch, currentGroup]); + }, [currentGroup]); const handleScrollToGroup = useCallback((group: GroupType) => { - // Set current group and scroll to group header dataModelDispatch({ type: "SET_CURRENT_GROUP", payload: group.Name }); if (group.Entities.length > 0) @@ -133,13 +121,13 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { } }, [dataModelDispatch, scrollToGroup, isMobile, closeSidebar]); - const handleSectionClick = useCallback((sectionId: string, groupName: string) => { + const handleEntityClick = useCallback((entity: EntityType, groupName: string) => { // Use requestAnimationFrame to defer heavy operations requestAnimationFrame(() => { dataModelDispatch({ type: 'SET_LOADING', payload: true }); - dataModelDispatch({ type: 'SET_LOADING_SECTION', payload: sectionId }); + dataModelDispatch({ type: 'SET_LOADING_SECTION', payload: entity.SchemaName }); dataModelDispatch({ type: "SET_CURRENT_GROUP", payload: groupName }); - dataModelDispatch({ type: 'SET_CURRENT_SECTION', payload: sectionId }); + dataModelDispatch({ type: 'SET_CURRENT_SECTION', payload: entity.SchemaName }); // On phone - close if (!!isMobile) { @@ -149,156 +137,18 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { // Defer scroll operation to next frame to prevent blocking requestAnimationFrame(() => { if (scrollToSection) { - scrollToSection(sectionId); + scrollToSection(entity.SchemaName); } clearSearch(); }); }); - }, [dataModelDispatch, scrollToSection, clearSearch]); + }, [dataModelDispatch, scrollToSection, clearSearch, isMobile, closeSidebar]); - const NavItem = useCallback(({ group }: INavItemProps) => { - const isCurrentGroup = currentGroup?.toLowerCase() === group.Name.toLowerCase(); - const isExpanded = expandedGroups.has(group.Name) || isCurrentGroup; - - return ( - handleGroupClick(group.Name)} - className={`group/accordion w-full first:rounded-t-lg last:rounded-b-lg shadow-none p-1`} - slotProps={{ - transition: { - timeout: 300, - } - }} - sx={{ - backgroundColor: "background.paper", - borderColor: 'border.main', - '& .MuiCollapse-root': { - transition: 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)', - } - }} - > - } - className={cn( - "p-2 duration-200 flex items-center rounded-md text-xs font-semibold text-sidebar-foreground/80 outline-none ring-sidebar-ring transition-all focus-visible:ring-2 cursor-pointer w-full min-w-0", - isCurrentGroup ? "font-semibold" : "hover:bg-sidebar-accent hover:text-sidebar-primary" - )} - sx={{ - backgroundColor: isExpanded ? alpha(theme.palette.primary.main, 0.1) : 'transparent', - padding: '4px', - minHeight: '32px !important', - '& .MuiAccordionSummary-content': { - margin: 0, - alignItems: 'center', - minWidth: 0, - overflow: 'hidden' - } - }} - > - - {group.Name} - - {group.Entities.length} - - { - e.stopPropagation(); - handleScrollToGroup(group); - }} - aria-label={`Link to first entity in ${group.Name}`} - className="w-4 h-4 flex-shrink-0" - sx={{ - color: isExpanded ? "primary.main" : "default" - }} - /> - - - - {group.Entities.map(entity => { - const isCurrentSection = currentSection?.toLowerCase() === entity.SchemaName.toLowerCase() - const isMatch = isEntityMatch(entity); - const isLoading = loadingSection === entity.SchemaName; - - // If searching and this entity doesn't match, don't render it - if (searchTerm.trim() && !isMatch) { - return null; - } - - return ( -
- ) : ( -
- ) - ) : ( - - )} - - {isMatch ? highlightText(entity.DisplayName, searchTerm) : entity.DisplayName} - - {isLoading && ( - - )} - - ) - })} - - - - ) - }, [currentGroup, currentSection, theme, handleGroupClick, handleSectionClick, isEntityMatch, searchTerm, highlightText, expandedGroups, loadingSection]); + const isEntityMatch = useCallback((entity: EntityType) => { + if (!searchTerm.trim()) return false; + return entity.SchemaName.toLowerCase().includes(searchTerm.toLowerCase()) || + entity.DisplayName.toLowerCase().includes(searchTerm.toLowerCase()); + }, [searchTerm]); return ( @@ -319,11 +169,23 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { /> - { - filteredGroups.map((group) => - - ) - } + {filteredGroups.map((group) => ( + + ))} ); diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index ac4c1f0..96572e9 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -1,4 +1,4 @@ -import { Box, Tooltip, Typography, Grid, TextField } from '@mui/material'; +import { Box, Tooltip, Typography, Grid, TextField, Divider } from '@mui/material'; import React, { useState } from 'react'; import { AddSquareIcon } from '@/lib/icons'; import { EntitySelectionPane } from './panes/EntitySelectionPane'; @@ -59,6 +59,8 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { Elements + + {diagramTools.map((tool) => ( @@ -66,7 +68,7 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { {tool.icon} diff --git a/Website/components/diagramview/panes/EntitySelectionPane.tsx b/Website/components/diagramview/panes/EntitySelectionPane.tsx index daf2d6b..5681fd1 100644 --- a/Website/components/diagramview/panes/EntitySelectionPane.tsx +++ b/Website/components/diagramview/panes/EntitySelectionPane.tsx @@ -1,29 +1,21 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { Drawer, Box, Typography, - List, - ListItem, - ListItemButton, - ListItemText, - Accordion, - AccordionSummary, - AccordionDetails, IconButton, - Divider + Divider, + Paper } from '@mui/material'; import { - Close as CloseIcon, - ExpandMore as ExpandMoreIcon, - FolderOpen as GroupIcon, - TableChart as EntityIcon + Close as CloseIcon } from '@mui/icons-material'; import { useDatamodelData } from '@/contexts/DatamodelDataContext'; import { useDiagramView } from '@/contexts/DiagramViewContext'; import { GroupType, EntityType } from '@/lib/Types'; +import { EntityGroupAccordion } from '@/components/shared/elements/EntityGroupAccordion'; interface EntitySelectionPaneProps { open: boolean; @@ -33,9 +25,9 @@ interface EntitySelectionPaneProps { export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) => { const { groups } = useDatamodelData(); const { addEntity, zoom, translate } = useDiagramView(); - const [expandedGroups, setExpandedGroups] = useState>(new Map()); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); - const handleEntitySelect = (entity: EntityType) => { + const handleEntitySelect = useCallback((entity: EntityType, groupName: string) => { // Add entity at center of current view const centerX = (-translate.x / zoom) + (400 / zoom); const centerY = (-translate.y / zoom) + (300 / zoom); @@ -45,7 +37,19 @@ export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) // Close the pane after selection onClose(); - }; + }, [translate, zoom, addEntity, onClose]); + + const handleGroupToggle = useCallback((groupName: string) => { + setExpandedGroups(prev => { + const newExpanded = new Set(prev); + if (newExpanded.has(groupName)) { + newExpanded.delete(groupName); + } else { + newExpanded.add(groupName); + } + return newExpanded; + }); + }, []); return ( ) : ( - + {groups.map((group: GroupType) => ( - { - setExpandedGroups(prev => { - const newMap = new Map(prev); - if (isExpanded) { - newMap.set(group.Name, true); - } else { - newMap.delete(group.Name); - } - return newMap; - }); - }} - sx={{ - boxShadow: 'none', - '&:before': { display: 'none' }, - '& .MuiAccordionSummary-root': { - minHeight: 48, - '&.Mui-expanded': { minHeight: 48 } - } - }} - > - } - sx={{ - '& .MuiAccordionSummary-content': { - alignItems: 'center', - gap: 1 - } - }} - > - - - {group.Name} - - - ({group.Entities.length} entities) - - - - - {group.Entities.map((entity: EntityType) => ( - - handleEntitySelect(entity)} - sx={{ - borderRadius: 1, - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - ))} - - - + group={group} + isExpanded={expandedGroups.has(group.Name)} + onToggle={handleGroupToggle} + onEntityClick={handleEntitySelect} + showGroupClickIcon={false} + /> ))} - + )} diff --git a/Website/components/shared/elements/EntityGroupAccordion.tsx b/Website/components/shared/elements/EntityGroupAccordion.tsx new file mode 100644 index 0000000..171695d --- /dev/null +++ b/Website/components/shared/elements/EntityGroupAccordion.tsx @@ -0,0 +1,210 @@ +'use client'; + +import React, { useCallback } from 'react'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Box, + Typography, + Button, + CircularProgress +} from '@mui/material'; +import { + ExpandMore, + OpenInNewRounded, + ExtensionRounded +} from '@mui/icons-material'; +import { useTheme, alpha } from '@mui/material/styles'; +import { cn } from "@/lib/utils"; +import { GroupType, EntityType } from "@/lib/Types"; + +interface EntityGroupAccordionProps { + group: GroupType; + isExpanded: boolean; + onToggle: (groupName: string) => void; + onEntityClick: (entity: EntityType, groupName: string) => void; + onGroupClick?: (group: GroupType) => void; + currentSection?: string | null; + currentGroup?: string | null; + loadingSection?: string | null; + searchTerm?: string; + highlightText?: (text: string, searchTerm: string) => React.ReactNode; + isEntityMatch?: (entity: EntityType) => boolean; + showGroupClickIcon?: boolean; +} + +export const EntityGroupAccordion = ({ + group, + isExpanded, + onToggle, + onEntityClick, + onGroupClick, + currentSection, + currentGroup, + loadingSection, + searchTerm = '', + highlightText, + isEntityMatch, + showGroupClickIcon = false +}: EntityGroupAccordionProps) => { + const theme = useTheme(); + const isCurrentGroup = currentGroup?.toLowerCase() === group.Name.toLowerCase(); + + const handleGroupClick = useCallback(() => { + onToggle(group.Name); + }, [onToggle, group.Name]); + + const handleGroupIconClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (onGroupClick) { + onGroupClick(group); + } + }, [onGroupClick, group]); + + const handleEntityButtonClick = useCallback((entity: EntityType) => { + onEntityClick(entity, group.Name); + }, [onEntityClick, group.Name]); + + return ( + + } + className={cn( + "p-2 duration-200 flex items-center rounded-md text-xs font-semibold text-sidebar-foreground/80 outline-none ring-sidebar-ring transition-all focus-visible:ring-2 cursor-pointer w-full min-w-0", + isCurrentGroup ? "font-semibold" : "hover:bg-sidebar-accent hover:text-sidebar-primary" + )} + sx={{ + backgroundColor: isExpanded ? alpha(theme.palette.primary.main, 0.1) : 'transparent', + padding: '4px', + minHeight: '32px !important', + '& .MuiAccordionSummary-content': { + margin: 0, + alignItems: 'center', + minWidth: 0, + overflow: 'hidden' + } + }} + > + + {group.Name} + + + {group.Entities.length} + + + {showGroupClickIcon && ( + + )} + + + + {group.Entities.map(entity => { + const isCurrentSection = currentSection?.toLowerCase() === entity.SchemaName.toLowerCase(); + const isMatch = isEntityMatch ? isEntityMatch(entity) : false; + const isLoading = loadingSection === entity.SchemaName; + + // If searching and this entity doesn't match, don't render it + if (searchTerm.trim() && !isMatch) { + return null; + } + + return ( + + ); + })} + + + + ); +}; \ No newline at end of file From 8d11897eeff1a33dab1866bfa20ebd8fd03adc82 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Thu, 16 Oct 2025 20:29:04 +0200 Subject: [PATCH 24/51] feat: peak older version of loaded diagram --- Website/app/api/diagram/list/route.ts | 2 +- Website/app/api/diagram/load/route.ts | 2 +- Website/app/api/diagram/save/route.ts | 2 +- Website/app/api/diagram/version/route.ts | 58 ++++ Website/app/api/diagram/versions/route.ts | 59 +++++ .../AzureDevOpsService.ts | 192 +++++++++++++- .../diagramview/DiagramHeaderToolbar.tsx | 26 +- .../panes/VersionHistorySidepane.tsx | 250 ++++++++++++++++++ .../smaller-components/HeaderDropdownMenu.tsx | 1 + .../smaller-components/HeaderMenuItem.tsx | 13 +- Website/lib/icons.tsx | 5 + 11 files changed, 597 insertions(+), 13 deletions(-) create mode 100644 Website/app/api/diagram/version/route.ts create mode 100644 Website/app/api/diagram/versions/route.ts rename Website/app/api/{auth/azuredevops => services}/AzureDevOpsService.ts (60%) create mode 100644 Website/components/diagramview/panes/VersionHistorySidepane.tsx diff --git a/Website/app/api/diagram/list/route.ts b/Website/app/api/diagram/list/route.ts index 2c733e1..fb1ff27 100644 --- a/Website/app/api/diagram/list/route.ts +++ b/Website/app/api/diagram/list/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { listFilesFromRepo, type GitItem } from '../../auth/azuredevops/AzureDevOpsService'; +import { listFilesFromRepo, type GitItem } from '../../services/AzureDevOpsService'; interface DiagramMetadata { path: string; diff --git a/Website/app/api/diagram/load/route.ts b/Website/app/api/diagram/load/route.ts index d3f7252..9bfa1f8 100644 --- a/Website/app/api/diagram/load/route.ts +++ b/Website/app/api/diagram/load/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { pullFileFromRepo } from '../../auth/azuredevops/AzureDevOpsService'; +import { pullFileFromRepo } from '../../services/AzureDevOpsService'; export async function POST(request: NextRequest) { try { diff --git a/Website/app/api/diagram/save/route.ts b/Website/app/api/diagram/save/route.ts index 299f794..1ad9c05 100644 --- a/Website/app/api/diagram/save/route.ts +++ b/Website/app/api/diagram/save/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { commitFileToRepo } from '../../auth/azuredevops/AzureDevOpsService'; +import { commitFileToRepo } from '../../services/AzureDevOpsService'; import { SerializedDiagram } from '@/lib/diagram/models/serialized-diagram'; interface DiagramSaveData extends SerializedDiagram { diff --git a/Website/app/api/diagram/version/route.ts b/Website/app/api/diagram/version/route.ts new file mode 100644 index 0000000..01ea6b7 --- /dev/null +++ b/Website/app/api/diagram/version/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { pullFileVersion, type LoadFileVersionOptions } from '../../services/AzureDevOpsService'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const filePath = searchParams.get('filePath'); + const commitId = searchParams.get('commitId'); + const repositoryName = searchParams.get('repositoryName') || undefined; + + if (!filePath) { + return NextResponse.json( + { error: 'filePath parameter is required' }, + { status: 400 } + ); + } + + if (!commitId) { + return NextResponse.json( + { error: 'commitId parameter is required' }, + { status: 400 } + ); + } + + const options: LoadFileVersionOptions = { + filePath, + commitId, + repositoryName + }; + + const fileContent = await pullFileVersion(options); + + return NextResponse.json({ + success: true, + filePath, + commitId, + content: fileContent + }); + + } catch (error) { + console.error('Error loading file version:', error); + + if (error instanceof Error) { + return NextResponse.json( + { + error: 'Failed to load file version', + details: error.message + }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'An unexpected error occurred' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/Website/app/api/diagram/versions/route.ts b/Website/app/api/diagram/versions/route.ts new file mode 100644 index 0000000..760257c --- /dev/null +++ b/Website/app/api/diagram/versions/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { listFileVersions, type FileVersionOptions } from '../../services/AzureDevOpsService'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const filePath = searchParams.get('filePath'); + const repositoryName = process.env.ADO_REPOSITORY_NAME || ''; + const maxVersionsParam = searchParams.get('maxVersions'); + + if (!filePath) { + return NextResponse.json( + { error: 'filePath parameter is required' }, + { status: 400 } + ); + } + + const maxVersions = maxVersionsParam ? parseInt(maxVersionsParam, 10) : undefined; + + if (maxVersionsParam && (isNaN(maxVersions!) || maxVersions! <= 0)) { + return NextResponse.json( + { error: 'maxVersions must be a positive number' }, + { status: 400 } + ); + } + + const options: FileVersionOptions = { + filePath, + repositoryName, + maxVersions + }; + + const versions = await listFileVersions(options); + + return NextResponse.json({ + success: true, + filePath, + versions + }); + + } catch (error) { + console.error('Error listing file versions:', error); + + if (error instanceof Error) { + return NextResponse.json( + { + error: 'Failed to list file versions', + details: error.message + }, + { status: 500 } + ); + } + + return NextResponse.json( + { error: 'An unexpected error occurred' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts b/Website/app/api/services/AzureDevOpsService.ts similarity index 60% rename from Website/app/api/auth/azuredevops/AzureDevOpsService.ts rename to Website/app/api/services/AzureDevOpsService.ts index 25686d9..24b3ec3 100644 --- a/Website/app/api/auth/azuredevops/AzureDevOpsService.ts +++ b/Website/app/api/services/AzureDevOpsService.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { managedAuth } from './ManagedIdentityAuthService'; +import { managedAuth } from '../auth/azuredevops/ManagedIdentityAuthService'; interface CreateFileOptions { filePath: string; @@ -50,6 +50,35 @@ interface GitCommitResponse { comment: string; } +interface FileVersionOptions { + filePath: string; + repositoryName?: string; + maxVersions?: number; // Optional limit on number of versions to return +} + +interface FileVersion { + commitId: string; + author: { + name: string; + email: string; + date: string; + }; + committer: { + name: string; + email: string; + date: string; + }; + comment: string; + changeType: string; // add, edit, delete, etc. + objectId: string; +} + +interface LoadFileVersionOptions { + filePath: string; + commitId: string; + repositoryName?: string; +} + class AzureDevOpsError extends Error { constructor(message: string, public statusCode?: number, public response?: unknown) { super(message); @@ -271,6 +300,156 @@ export async function pullFileFromRepo(options: LoadFileOptions): Promise } } +/** + * Lists all versions (commits) of a specific file in the Azure DevOps Git repository + * @param options Configuration for file version retrieval + * @returns Promise with array of file versions + */ +export async function listFileVersions(options: FileVersionOptions): Promise { + const { + filePath, + repositoryName, + maxVersions = 50 // Default to 50 versions + } = options; + + try { + // Get ADO configuration + const config = managedAuth.getConfig(); + + // Validate inputs + if (!filePath) { + throw new AzureDevOpsError('File path is required'); + } + + // Construct the API URL for getting file commit history + const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`; + const commitsUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/commits?searchCriteria.$top=${maxVersions}&searchCriteria.itemPath=${normalizedPath}&api-version=7.0`; + + console.log(commitsUrl) + + const response = await managedAuth.makeAuthenticatedRequest(commitsUrl); + + if (!response.ok) { + if (response.status === 404) { + throw new AzureDevOpsError(`File not found: ${filePath}`, 404); + } + const errorText = await response.text(); + throw new AzureDevOpsError(`Failed to get file versions: ${response.status} - ${errorText}`, response.status); + } + + const commitsData = await response.json(); + + if (!commitsData.value || !Array.isArray(commitsData.value)) { + return []; + } + + // Get detailed change information for each commit + const versions: FileVersion[] = []; + + for (const commit of commitsData.value) { + try { + // Get the changes for this specific commit to determine the change type + const changesUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/commits/${commit.commitId}/changes?api-version=7.0`; + const changesResponse = await managedAuth.makeAuthenticatedRequest(changesUrl); + + if (changesResponse.ok) { + const changesData = await changesResponse.json(); + const fileChange = changesData.changes?.find((change: any) => + change.item?.path === normalizedPath + ); + + if (fileChange) { + versions.push({ + commitId: commit.commitId, + author: commit.author, + committer: commit.committer, + comment: commit.comment, + changeType: fileChange.changeType || 'edit', + objectId: fileChange.item?.objectId || commit.commitId + }); + } + } else { + // Fallback: add commit without detailed change info + versions.push({ + commitId: commit.commitId, + author: commit.author, + committer: commit.committer, + comment: commit.comment, + changeType: 'edit', // Default assumption + objectId: commit.commitId + }); + } + } catch (error) { + // Continue with other commits if one fails + console.warn(`Failed to get changes for commit ${commit.commitId}:`, error); + } + } + + return versions; + + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new AzureDevOpsError(`Unexpected error listing file versions: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Loads a specific version of a file from the Azure DevOps Git repository + * @param options Configuration for file version loading + * @returns Promise with parsed JSON content from the specified version + */ +export async function pullFileVersion(options: LoadFileVersionOptions): Promise { + const { + filePath, + commitId, + repositoryName + } = options; + + try { + // Get ADO configuration + const config = managedAuth.getConfig(); + + // Validate inputs + if (!filePath || !commitId) { + throw new AzureDevOpsError('File path and commit ID are required'); + } + + // Construct the API URL for getting file content at specific commit + const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + const fileUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/items?path=/${normalizedPath}&versionDescriptor.version=${commitId}&versionDescriptor.versionType=commit&includeContent=true&api-version=7.0`; + + const response = await managedAuth.makeAuthenticatedRequest(fileUrl); + + if (!response.ok) { + if (response.status === 404) { + throw new AzureDevOpsError(`File not found at commit ${commitId}: ${filePath}`, 404); + } + const errorText = await response.text(); + throw new AzureDevOpsError(`Failed to load file version: ${response.status} - ${errorText}`, response.status); + } + + const fileData: GitFileResponse = await response.json(); + + if (!fileData.content) { + throw new AzureDevOpsError(`File content is empty at commit ${commitId}: ${filePath}`); + } + + try { + return JSON.parse(fileData.content) as T; + } catch (parseError) { + throw new AzureDevOpsError(`Failed to parse JSON content: ${parseError instanceof Error ? parseError.message : String(parseError)}`); + } + + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new AzureDevOpsError(`Unexpected error loading file version: ${error instanceof Error ? error.message : String(error)}`); + } +} + // Helper function to get repository information using environment variable export async function getRepositoryInfo(repositoryName?: string): Promise<{ id: string; name: string; webUrl: string }> { try { @@ -306,5 +485,14 @@ export async function getRepositoryInfo(repositoryName?: string): Promise<{ id: } // Export types for external use -export type { CreateFileOptions, LoadFileOptions, GitCommitResponse, GitFileResponse, GitItem }; +export type { + CreateFileOptions, + LoadFileOptions, + FileVersionOptions, + LoadFileVersionOptions, + GitCommitResponse, + GitFileResponse, + GitItem, + FileVersion +}; export { AzureDevOpsError }; \ No newline at end of file diff --git a/Website/components/diagramview/DiagramHeaderToolbar.tsx b/Website/components/diagramview/DiagramHeaderToolbar.tsx index 8201e6d..80bcc7e 100644 --- a/Website/components/diagramview/DiagramHeaderToolbar.tsx +++ b/Website/components/diagramview/DiagramHeaderToolbar.tsx @@ -1,23 +1,25 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import { Box, Chip, useTheme, alpha } from '@mui/material'; import { HeaderDropdownMenu, MenuItemConfig } from './smaller-components/HeaderDropdownMenu'; -import { CloudNewIcon, CloudSaveIcon, FileMenuIcon, LoadIcon, LocalSaveIcon, NewIcon } from '@/lib/icons'; +import { ArchiveIcon, CloudNewIcon, CloudSaveIcon, FileMenuIcon, LoadIcon, LocalSaveIcon, NewIcon } from '@/lib/icons'; import { SaveDiagramModal } from './modals/SaveDiagramModal'; import { LoadDiagramModal } from './modals/LoadDiagramModal'; +import { VersionHistorySidepane } from './panes/VersionHistorySidepane'; import { useDiagramSave } from '@/hooks/useDiagramSave'; import { useDiagramLoad } from '@/hooks/useDiagramLoad'; import { useDiagramView } from '@/contexts/DiagramViewContext'; import { useRepositoryInfo } from '@/hooks/useRepositoryInfo'; import { CheckRounded, ErrorRounded, WarningRounded } from '@mui/icons-material'; +import HeaderMenuItem from './smaller-components/HeaderMenuItem'; interface IDiagramHeaderToolbarProps { // No props needed - actions are handled internally } export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { - const { hasLoadedDiagram, loadedDiagramSource} = useDiagramView(); + const { hasLoadedDiagram, loadedDiagramSource, loadedDiagramFilePath} = useDiagramView(); const { isSaving, showSaveModal, saveDiagramToCloud, saveDiagramLocally, closeSaveModal, createNewDiagram } = useDiagramSave(); const { isLoading, @@ -31,6 +33,7 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { closeLoadModal } = useDiagramLoad(); const { isCloudConfigured, isLoading: isRepoInfoLoading } = useRepositoryInfo(); + const [showVersionHistory, setShowVersionHistory] = useState(false); const theme = useTheme(); @@ -77,13 +80,21 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { return ( <> - + + + setShowVersionHistory(true)} + new={false} + disabled={!hasLoadedDiagram || !loadedDiagramFilePath} /> @@ -142,6 +153,11 @@ export const DiagramHeaderToolbar = ({ }: IDiagramHeaderToolbarProps) => { onLoadFromFile={loadDiagramFromFile} onLoadAvailableDiagrams={loadAvailableDiagrams} /> + + setShowVersionHistory(false)} + /> ); }; \ No newline at end of file diff --git a/Website/components/diagramview/panes/VersionHistorySidepane.tsx b/Website/components/diagramview/panes/VersionHistorySidepane.tsx new file mode 100644 index 0000000..0dde454 --- /dev/null +++ b/Website/components/diagramview/panes/VersionHistorySidepane.tsx @@ -0,0 +1,250 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Drawer, + Box, + Typography, + IconButton, + List, + ListItem, + ListItemText, + Divider, + CircularProgress, + Alert, + Chip, + Skeleton, + Button, + Tooltip +} from '@mui/material'; +import { Close as CloseIcon } from '@mui/icons-material'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; + +interface FileVersion { + commitId: string; + author: { + name: string; + email: string; + date: string; + }; + committer: { + name: string; + email: string; + date: string; + }; + comment: string; + changeType: string; + objectId: string; +} + +interface VersionHistorySidepaneProps { + open: boolean; + onClose: () => void; +} + +export const VersionHistorySidepane: React.FC = ({ + open, + onClose +}) => { + const { loadedDiagramFilePath } = useDiagramView(); + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchVersions = async () => { + if (!loadedDiagramFilePath) { + setError('No diagram loaded'); + return; + } + + setLoading(true); + setError(null); + setVersions([]); + + try { + const params = new URLSearchParams({ + filePath: loadedDiagramFilePath, + maxVersions: '20' + }); + + const response = await fetch(`/api/diagram/versions?${params}`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to fetch versions'); + } + + if (data.success) { + setVersions(data.versions); + } else { + throw new Error('Unexpected response format'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (open && loadedDiagramFilePath) { + fetchVersions(); + } + }, [open, loadedDiagramFilePath]); + + const formatRelativeTime = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + + if (diffDays > 0) { + return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + } else if (diffHours > 0) { + return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + } else if (diffMinutes > 0) { + return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`; + } else { + return 'Just now'; + } + }; + + const getChangeTypeColor = (changeType: string) => { + switch (changeType.toLowerCase()) { + case 'add': + return 'success'; + case 'edit': + return 'primary'; + case 'delete': + return 'error'; + default: + return 'default'; + } + }; + + const renderVersionSkeleton = () => ( + + + + + + + + + + + + + + + ); + + return ( + + + + + Version History + + + + + + {loadedDiagramFilePath && ( + + {loadedDiagramFilePath} + + )} + + + + {!loadedDiagramFilePath ? ( + + Load a diagram to view its version history + + ) : error ? ( + + {error} + + ) : ( + + {loading ? ( + // Show skeleton loaders while loading + Array.from({ length: 5 }).map((_, index) => ( + + {renderVersionSkeleton()} + {index < 4 && } + + )) + ) : versions.length === 0 ? ( + + No version history found for this file + + ) : ( + versions.map((version, index) => ( + + + + + + {formatRelativeTime(version.author.date)} + + + } + secondary={ + + + + {version.comment} + + + by {version.author.name} + + + {version.commitId.substring(0, 8)} + + + + + } + /> + + {index < versions.length - 1 && } + + )) + )} + + )} + + + ); +}; \ No newline at end of file diff --git a/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx index eab8f3d..e0a76e8 100644 --- a/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx +++ b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx @@ -61,6 +61,7 @@ export const HeaderDropdownMenu: React.FC = ({ action={handleMenuClick} new={isNew} disabled={disabled} + isDropdown={true} /> ) => void; } -const HeaderMenuItem = ({ icon, label, tooltip, new: isNew, disabled, action }: IHeaderMenuItemProps) => { +const HeaderMenuItem = ({ icon, label, tooltip, new: isNew, disabled, action, isDropdown }: IHeaderMenuItemProps) => { return ( - + - {icon} + {icon} {label} + {isDropdown && ( + + + + )} diff --git a/Website/lib/icons.tsx b/Website/lib/icons.tsx index ae1d04f..a1df6ec 100644 --- a/Website/lib/icons.tsx +++ b/Website/lib/icons.tsx @@ -81,4 +81,9 @@ export const NewIcon = +; + +export const ArchiveIcon = + + ; \ No newline at end of file From cb1c72e8a8fb2c9d8e0c930e59a11ff5bc259464 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 18 Oct 2025 11:28:58 +0200 Subject: [PATCH 25/51] feat: insert new entities in the center of the canvas --- Website/app/diagram/page.tsx | 13 ++++---- Website/app/insights/page.tsx | 5 +--- Website/app/layout.tsx | 5 ++-- Website/app/metadata/page.tsx | 5 ++-- Website/app/processes/page.tsx | 17 +++++------ .../diagramview/modals/LoadDiagramModal.tsx | 4 +-- .../diagramview/panes/EntitySelectionPane.tsx | 5 +--- Website/contexts/DiagramViewContext.tsx | 30 +++++++++++++++---- 8 files changed, 47 insertions(+), 37 deletions(-) diff --git a/Website/app/diagram/page.tsx b/Website/app/diagram/page.tsx index 6c90bee..d37e14b 100644 --- a/Website/app/diagram/page.tsx +++ b/Website/app/diagram/page.tsx @@ -2,20 +2,17 @@ import DiagramView from "@/components/diagramview/DiagramView"; import Layout from "@/components/shared/Layout"; -import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; import { DiagramViewProvider } from "@/contexts/DiagramViewContext"; import { Suspense } from "react"; export default function Home() { return ( - - - - - - - + + + + + ) } \ No newline at end of file diff --git a/Website/app/insights/page.tsx b/Website/app/insights/page.tsx index 0c0abb1..458f923 100644 --- a/Website/app/insights/page.tsx +++ b/Website/app/insights/page.tsx @@ -5,14 +5,11 @@ import { useRouter, useSearchParams } from "next/navigation"; import Layout from "@/components/shared/Layout"; import InsightsView from "@/components/insightsview/InsightsView"; import { Suspense } from "react"; -import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; export default function Insights() { return ( - - - + ) } diff --git a/Website/app/layout.tsx b/Website/app/layout.tsx index dccc646..58a53c7 100644 --- a/Website/app/layout.tsx +++ b/Website/app/layout.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from "@/contexts/AuthContext"; import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter'; import { DatamodelViewProvider } from "@/contexts/DatamodelViewContext"; import { SnackbarProvider } from "@/contexts/SnackbarContext"; +import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; export const metadata: Metadata = { title: "Data Model Viewer", @@ -29,13 +30,13 @@ export default function RootLayout({ - + {children} - + diff --git a/Website/app/metadata/page.tsx b/Website/app/metadata/page.tsx index b422cff..90cd881 100644 --- a/Website/app/metadata/page.tsx +++ b/Website/app/metadata/page.tsx @@ -2,15 +2,16 @@ import { DatamodelView } from "@/components/datamodelview/DatamodelView"; import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; import Layout from "@/components/shared/Layout"; import { Suspense } from "react"; +import { DatamodelViewProvider } from "@/contexts/DatamodelViewContext"; export default function Data() { return ( - + - + ) } diff --git a/Website/app/processes/page.tsx b/Website/app/processes/page.tsx index dc2d539..3d54d14 100644 --- a/Website/app/processes/page.tsx +++ b/Website/app/processes/page.tsx @@ -1,16 +1,13 @@ import { ProcessesView } from '@/components/processesview/ProcessesView'; -import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; import Layout from '@/components/shared/Layout'; import React, { Suspense } from 'react' export default function Processes() { - return ( - - - - - - - - ) + return ( + + + + + + ) } diff --git a/Website/components/diagramview/modals/LoadDiagramModal.tsx b/Website/components/diagramview/modals/LoadDiagramModal.tsx index 6192a59..788d438 100644 --- a/Website/components/diagramview/modals/LoadDiagramModal.tsx +++ b/Website/components/diagramview/modals/LoadDiagramModal.tsx @@ -202,14 +202,14 @@ export const LoadDiagramModal = ({ + Updated: {formatDate(diagram.updatedAt)} Size: {formatFileSize(diagram.size)} - + } /> diff --git a/Website/components/diagramview/panes/EntitySelectionPane.tsx b/Website/components/diagramview/panes/EntitySelectionPane.tsx index 5681fd1..7a6b2af 100644 --- a/Website/components/diagramview/panes/EntitySelectionPane.tsx +++ b/Website/components/diagramview/panes/EntitySelectionPane.tsx @@ -28,12 +28,9 @@ export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) const [expandedGroups, setExpandedGroups] = useState>(new Set()); const handleEntitySelect = useCallback((entity: EntityType, groupName: string) => { - // Add entity at center of current view - const centerX = (-translate.x / zoom) + (400 / zoom); - const centerY = (-translate.y / zoom) + (300 / zoom); // Use DisplayName for the entity label - addEntity({ x: centerX, y: centerY }, entity.DisplayName); + addEntity(undefined, entity.DisplayName); // Close the pane after selection onClose(); diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index f949e7b..90d125f 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -319,11 +319,31 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { // Context functions const addEntity = (position?: { x: number; y: number }, label?: string) => { if (graphRef.current && paperRef.current) { - const x = position?.x ?? 100; - const y = position?.y ?? 100; + let entityX: number; + let entityY: number; - // Convert position if it's in screen coordinates - const paperPoint = paperRef.current.clientToLocalPoint({ x, y }); + if (position) { + // If position is provided, use it as-is (already in paper coordinates) + entityX = position.x; + entityY = position.y; + } else { + // Calculate the center of the current viewport + const canvasElement = diagramViewState.canvas.current!; + const canvasRect = canvasElement.getBoundingClientRect(); + + // Get the center point of the visible canvas in screen coordinates + const centerScreenX = canvasRect.left + (canvasRect.width / 2); + const centerScreenY = canvasRect.top + (canvasRect.height / 2); + + // Convert screen coordinates to paper coordinates + const centerPaperPoint = paperRef.current.clientToLocalPoint({ + x: centerScreenX, + y: centerScreenY + }); + + entityX = centerPaperPoint.x; + entityY = centerPaperPoint.y; + } // Theme-aware entity colors using MUI CSS variables const colors = [ @@ -343,7 +363,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { const textColor = 'var(--mui-palette-primary-contrastText)'; const rect = new shapes.standard.Rectangle({ - position: { x: paperPoint.x - 60, y: paperPoint.y - 40 }, + position: { x: entityX - 60, y: entityY - 40 }, size: { width: 120, height: 80 }, attrs: { body: { From c1e19a4d499ea073ef419c8b25bbe842c6c3da3e Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 18 Oct 2025 14:41:57 +0200 Subject: [PATCH 26/51] feat: rightclick entity event to open contextmenu. --- .../diagramview/DiagramContainer.tsx | 42 ++++++- .../diagram-elements/EntityElement.ts | 116 ++++++++++++++++++ .../smaller-components/EntityContextMenu.tsx | 41 +++++++ Website/contexts/DiagramViewContext.tsx | 45 ++----- .../services/diagram-deserialization.ts | 18 +-- 5 files changed, 210 insertions(+), 52 deletions(-) create mode 100644 Website/components/diagramview/diagram-elements/EntityElement.ts create mode 100644 Website/components/diagramview/smaller-components/EntityContextMenu.tsx diff --git a/Website/components/diagramview/DiagramContainer.tsx b/Website/components/diagramview/DiagramContainer.tsx index 935a35f..0fab245 100644 --- a/Website/components/diagramview/DiagramContainer.tsx +++ b/Website/components/diagramview/DiagramContainer.tsx @@ -1,15 +1,47 @@ 'use client'; -import React from "react"; +import React, { useState, useEffect } from "react"; import { Box } from "@mui/material"; import { useDiagramView } from "@/contexts/DiagramViewContext"; +import { EntityContextMenu } from "./smaller-components/EntityContextMenu"; interface IDiagramContainerProps { } export default function DiagramContainer({ }: IDiagramContainerProps) { - const { canvas } = useDiagramView(); + const { canvas, getGraph, getPaper } = useDiagramView(); + const [contextMenu, setContextMenu] = useState<{ + open: boolean; + position: { top: number; left: number } | null; + entityId?: string; + }>({ + open: false, + position: null + }); + + useEffect(() => { + const handleEntityContextMenu = (evt: CustomEvent) => { + const { entityId, x, y } = evt.detail; + setContextMenu({ + open: true, + position: { top: y, left: x }, + entityId: entityId + }); + }; + window.addEventListener('entityContextMenu', handleEntityContextMenu as EventListener); + + return () => { + window.removeEventListener('entityContextMenu', handleEntityContextMenu as EventListener); + }; + }, []); + + const handleCloseContextMenu = () => { + setContextMenu({ + open: false, + position: null + }); + }; return ( + ); } diff --git a/Website/components/diagramview/diagram-elements/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts new file mode 100644 index 0000000..13422ae --- /dev/null +++ b/Website/components/diagramview/diagram-elements/EntityElement.ts @@ -0,0 +1,116 @@ +import { dia, util } from '@joint/core'; + +export const EntityElementView = dia.ElementView.extend({ + + events: { + 'click': 'onEntityClick', + 'dblclick': 'onTitleDoubleClick', + 'mouseenter': 'onMouseEnter', + 'mouseleave': 'onMouseLeave', + 'contextmenu': 'onContextMenu' + }, + + initialize: function(options?: any) { + dia.ElementView.prototype.initialize.call(this, options); + this.listenTo(this.model, 'change:label', this.updateTitle); + this.updateTitle(); + }, + + onEntityClick: function(evt: MouseEvent) { + this.preventDefaultInteraction(evt); + }, + + onTitleDoubleClick: function(evt: Event) { + evt.preventDefault(); + evt.stopPropagation(); + }, + + onMouseEnter: function() { + this.model.attr('container/style/cursor', 'move'); + }, + + onMouseLeave: function() { + this.model.attr('container/style/cursor', 'default'); + }, + + onContextMenu: function(evt: MouseEvent) { + evt.preventDefault(); + evt.stopPropagation(); + + const customEvent = new CustomEvent('entityContextMenu', { + detail: { + entityId: this.model.id, + x: evt.clientX, + y: evt.clientY + } + }); + window.dispatchEvent(customEvent); + }, + + updateTitle: function() { + const label = this.model.get('label') || 'Entity'; + this.model.attr('title/html', label); + }, +}); + +export const EntityElement = dia.Element.define('diagram.EntityElement', { + size: { width: 120, height: 80 }, + attrs: { + foreignObject: { + width: 'calc(w)', + height: 'calc(h)' + }, + container: { + style: { + width: '100%', + height: '100%', + backgroundColor: 'var(--mui-palette-background-paper)', + border: '2px solid var(--mui-palette-primary-main)', + borderRadius: '8px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '8px', + boxSizing: 'border-box' + } + }, + title: { + html: 'Entity', + style: { + margin: '0', + fontSize: '14px', + fontWeight: '600', + color: 'var(--mui-palette-text-primary)', + textAlign: 'center', + wordBreak: 'break-word' + } + } + } +}, { + markup: util.svg/* xml */` + +
+ +
+
+ ` +}); + +export function createEntity(options: { + position?: { x: number; y: number }; + title?: string; + size?: { width: number; height: number }; +} = {}) { + const label = options.title || 'New Entity'; + const entity = new EntityElement({ + position: options.position || { x: 0, y: 0 }, + size: options.size || { width: 120, height: 80 }, + label + }); + + return entity; +} \ No newline at end of file diff --git a/Website/components/diagramview/smaller-components/EntityContextMenu.tsx b/Website/components/diagramview/smaller-components/EntityContextMenu.tsx new file mode 100644 index 0000000..a695f46 --- /dev/null +++ b/Website/components/diagramview/smaller-components/EntityContextMenu.tsx @@ -0,0 +1,41 @@ +'use client'; + +import React from 'react'; +import { Menu, MenuItem } from '@mui/material'; + +interface EntityContextMenuProps { + anchorPosition?: { top: number; left: number } | null; + open: boolean; + onClose: () => void; + entityId?: string; +} + +export const EntityContextMenu: React.FC = ({ + anchorPosition, + open, + onClose, + entityId +}) => { + return ( + + + Edit Entity + + + ); +}; \ No newline at end of file diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 90d125f..0ffd608 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -1,5 +1,6 @@ import { dia, shapes } from '@joint/core'; import React, { createContext, useContext, ReactNode, useReducer, useEffect, useRef } from 'react'; +import { createEntity, EntityElement, EntityElementView } from '@/components/diagramview/diagram-elements/EntityElement'; interface DiagramActions { setZoom: (zoom: number) => void; @@ -158,7 +159,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { snapToGrid: true, frozen: true, async: true, - cellViewNamespace: shapes + cellViewNamespace: { ...shapes, diagram: { EntityElement, EntityElementView } } }); paperRef.current = paper; @@ -345,45 +346,17 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { entityY = centerPaperPoint.y; } - // Theme-aware entity colors using MUI CSS variables - const colors = [ - { fill: 'var(--mui-palette-primary-main)', stroke: 'var(--mui-palette-primary-dark)' }, - { fill: 'var(--mui-palette-success-main)', stroke: 'var(--mui-palette-success-dark)' }, - { fill: 'var(--mui-palette-warning-main)', stroke: 'var(--mui-palette-warning-dark)' }, - { fill: 'var(--mui-palette-error-main)', stroke: 'var(--mui-palette-error-dark)' }, - { fill: 'var(--mui-palette-secondary-main)', stroke: 'var(--mui-palette-secondary-dark)' }, - { fill: 'var(--mui-palette-info-main)', stroke: 'var(--mui-palette-info-dark)' }, - ]; - - const colorIndex = graphRef.current.getCells().length % colors.length; - const color = colors[colorIndex]; const entityLabel = label || `Entity ${graphRef.current.getCells().length + 1}`; - // Theme-aware text color using MUI variables - const textColor = 'var(--mui-palette-primary-contrastText)'; - - const rect = new shapes.standard.Rectangle({ - position: { x: entityX - 60, y: entityY - 40 }, - size: { width: 120, height: 80 }, - attrs: { - body: { - fill: color.fill, - stroke: color.stroke, - strokeWidth: 2, - rx: 8, - ry: 8 - }, - label: { - text: entityLabel, - fill: textColor, - fontSize: 14, - fontFamily: 'Arial, sans-serif' - } - } + // Create the new entity using our custom EntityElement + const entity = createEntity({ + position: { x: entityX - 60, y: entityY - 40 }, // Center the entity (120x80 default size) + title: entityLabel, + size: { width: 120, height: 80 } }); - graphRef.current.addCell(rect); - return rect; + graphRef.current.addCell(entity); + return entity; } return null; }; diff --git a/Website/lib/diagram/services/diagram-deserialization.ts b/Website/lib/diagram/services/diagram-deserialization.ts index 0618763..62ac098 100644 --- a/Website/lib/diagram/services/diagram-deserialization.ts +++ b/Website/lib/diagram/services/diagram-deserialization.ts @@ -1,6 +1,7 @@ import { dia, shapes } from '@joint/core'; import { SerializedDiagram } from '../models/serialized-diagram'; import { SerializedEntity } from '../models/serialized-entity'; +import { EntityElement } from '@/components/diagramview/diagram-elements/EntityElement'; export interface DiagramFile { path: string; @@ -64,25 +65,14 @@ export class DiagramDeserializationService { // Recreate entities diagramData.entities.forEach((entityData: SerializedEntity, index: number) => { - const rect = new shapes.standard.Rectangle({ + const entity = new EntityElement({ id: entityData.id, position: entityData.position, size: entityData.size, - attrs: { - body: { - strokeWidth: 2, - rx: 8, - ry: 8 - }, - label: { - text: entityData.label, - fontSize: 14, - fontFamily: 'Arial, sans-serif' - } - } + title: entityData.label }); - graph.addCell(rect); + graph.addCell(entity); }); } From 8d98e7a8584157c4b909c8215f5d38c906d9e944 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 18 Oct 2025 14:46:46 +0200 Subject: [PATCH 27/51] chore: about update --- Website/components/aboutview/AboutView.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Website/components/aboutview/AboutView.tsx b/Website/components/aboutview/AboutView.tsx index 4f579df..82c732b 100644 --- a/Website/components/aboutview/AboutView.tsx +++ b/Website/components/aboutview/AboutView.tsx @@ -127,6 +127,11 @@ export const AboutView = ({}: IAboutViewProps) => { + {/* Credits */} + + Icons by 480 Design Figma + + {/* Version */} From 96b2d55e78fbd8f85657349288fd09f9dee7704d Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Mon, 20 Oct 2025 18:58:51 +0200 Subject: [PATCH 28/51] feat: selection and multi selection of entities --- .../design/design.instructions.md | 3 + .../diagramview/DiagramContainer.tsx | 19 +- .../diagram-elements/EntityElement.ts | 41 +- .../diagramview/events/DiagramEvents.ts | 73 ++++ Website/contexts/DiagramViewContext.tsx | 396 +++++++++++++++++- 5 files changed, 512 insertions(+), 20 deletions(-) create mode 100644 Website/components/diagramview/events/DiagramEvents.ts diff --git a/Website/.github/instructions/design/design.instructions.md b/Website/.github/instructions/design/design.instructions.md index cdc0e67..944b336 100644 --- a/Website/.github/instructions/design/design.instructions.md +++ b/Website/.github/instructions/design/design.instructions.md @@ -50,3 +50,6 @@ const ComponentName = ({ }: ComponentNameProps) => { export default ComponentName; ``` +# Index files +You must not create index.ts files for component folders. + diff --git a/Website/components/diagramview/DiagramContainer.tsx b/Website/components/diagramview/DiagramContainer.tsx index 0fab245..015888c 100644 --- a/Website/components/diagramview/DiagramContainer.tsx +++ b/Website/components/diagramview/DiagramContainer.tsx @@ -4,13 +4,14 @@ import React, { useState, useEffect } from "react"; import { Box } from "@mui/material"; import { useDiagramView } from "@/contexts/DiagramViewContext"; import { EntityContextMenu } from "./smaller-components/EntityContextMenu"; +import { EntityContextMenuEvent, EntitySelectEvent, DiagramEventDispatcher } from "./events/DiagramEvents"; interface IDiagramContainerProps { } export default function DiagramContainer({ }: IDiagramContainerProps) { - const { canvas, getGraph, getPaper } = useDiagramView(); + const { canvas, selectEntity } = useDiagramView(); const [contextMenu, setContextMenu] = useState<{ open: boolean; position: { top: number; left: number } | null; @@ -21,7 +22,7 @@ export default function DiagramContainer({ }: IDiagramContainerProps) { }); useEffect(() => { - const handleEntityContextMenu = (evt: CustomEvent) => { + const handleEntityContextMenu = (evt: EntityContextMenuEvent) => { const { entityId, x, y } = evt.detail; setContextMenu({ open: true, @@ -29,12 +30,20 @@ export default function DiagramContainer({ }: IDiagramContainerProps) { entityId: entityId }); }; - window.addEventListener('entityContextMenu', handleEntityContextMenu as EventListener); + + const handleEntitySelect = (evt: EntitySelectEvent) => { + const { entityId, ctrlKey } = evt.detail; + selectEntity(entityId, ctrlKey); + }; + + DiagramEventDispatcher.addEventListener('entityContextMenu', handleEntityContextMenu); + DiagramEventDispatcher.addEventListener('entitySelect', handleEntitySelect); return () => { - window.removeEventListener('entityContextMenu', handleEntityContextMenu as EventListener); + DiagramEventDispatcher.removeEventListener('entityContextMenu', handleEntityContextMenu); + DiagramEventDispatcher.removeEventListener('entitySelect', handleEntitySelect); }; - }, []); + }, [selectEntity]); const handleCloseContextMenu = () => { setContextMenu({ diff --git a/Website/components/diagramview/diagram-elements/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts index 13422ae..980b24a 100644 --- a/Website/components/diagramview/diagram-elements/EntityElement.ts +++ b/Website/components/diagramview/diagram-elements/EntityElement.ts @@ -1,4 +1,5 @@ import { dia, util } from '@joint/core'; +import { DiagramEventDispatcher } from '../events/DiagramEvents'; export const EntityElementView = dia.ElementView.extend({ @@ -14,10 +15,37 @@ export const EntityElementView = dia.ElementView.extend({ dia.ElementView.prototype.initialize.call(this, options); this.listenTo(this.model, 'change:label', this.updateTitle); this.updateTitle(); + + // Listen for drag end events to mark this entity as having been dragged + this._dragEndHandler = (evt: CustomEvent) => { + const { entityIds } = evt.detail; + if (entityIds.includes(String(this.model.id))) { + this._wasDragged = true; + } + }; + window.addEventListener('entityDragEnd', this._dragEndHandler as EventListener); + }, + + remove: function() { + if (this._dragEndHandler) { + window.removeEventListener('entityDragEnd', this._dragEndHandler as EventListener); + } + dia.ElementView.prototype.remove.call(this); }, onEntityClick: function(evt: MouseEvent) { + // Don't select if this was part of a drag operation (otherwise click would fire post drag, meaning the selection would just be this entity) + if (this._wasDragged) { + this._wasDragged = false; + return; + } + this.preventDefaultInteraction(evt); + + DiagramEventDispatcher.dispatch('entitySelect', { + entityId: String(this.model.id), + ctrlKey: evt.ctrlKey + }); }, onTitleDoubleClick: function(evt: Event) { @@ -37,16 +65,15 @@ export const EntityElementView = dia.ElementView.extend({ evt.preventDefault(); evt.stopPropagation(); - const customEvent = new CustomEvent('entityContextMenu', { - detail: { - entityId: this.model.id, - x: evt.clientX, - y: evt.clientY - } + DiagramEventDispatcher.dispatch('entityContextMenu', { + entityId: String(this.model.id), + x: evt.clientX, + y: evt.clientY }); - window.dispatchEvent(customEvent); }, + + updateTitle: function() { const label = this.model.get('label') || 'Entity'; this.model.attr('title/html', label); diff --git a/Website/components/diagramview/events/DiagramEvents.ts b/Website/components/diagramview/events/DiagramEvents.ts new file mode 100644 index 0000000..d03112f --- /dev/null +++ b/Website/components/diagramview/events/DiagramEvents.ts @@ -0,0 +1,73 @@ +// Type definitions for diagram events +export interface EntitySelectEventDetail { + entityId: string; + ctrlKey: boolean; +} + +export interface EntityContextMenuEventDetail { + entityId: string; + x: number; + y: number; +} + +export interface EntityDragStartEventDetail { + entityId: string; + startX: number; + startY: number; +} + +export interface EntityDragEndEventDetail { + entityIds: string[]; +} + +// Custom event type definitions +export interface EntitySelectEvent extends CustomEvent { + detail: EntitySelectEventDetail; +} + +export interface EntityContextMenuEvent extends CustomEvent { + detail: EntityContextMenuEventDetail; +} + +export interface EntityDragStartEvent extends CustomEvent { + detail: EntityDragStartEventDetail; +} + +export interface EntityDragEndEvent extends CustomEvent { + detail: EntityDragEndEventDetail; +} + +// Event type map for type safety +export interface DiagramEventMap { + 'entitySelect': EntitySelectEvent; + 'entityContextMenu': EntityContextMenuEvent; + 'entityDragStart': EntityDragStartEvent; + 'entityDragEnd': EntityDragEndEvent; +} + +// Type-safe event dispatcher class +export class DiagramEventDispatcher { + static dispatch( + type: K, + detail: DiagramEventMap[K]['detail'] + ): void { + const event = new CustomEvent(type, { detail }) as DiagramEventMap[K]; + window.dispatchEvent(event); + } + + static addEventListener( + type: K, + listener: (event: DiagramEventMap[K]) => void, + options?: boolean | AddEventListenerOptions + ): void { + window.addEventListener(type, listener as EventListener, options); + } + + static removeEventListener( + type: K, + listener: (event: DiagramEventMap[K]) => void, + options?: boolean | EventListenerOptions + ): void { + window.removeEventListener(type, listener as EventListener, options); + } +} \ No newline at end of file diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 0ffd608..a201e6e 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -1,6 +1,7 @@ -import { dia, shapes } from '@joint/core'; +import { dia, shapes, mvc } from '@joint/core'; import React, { createContext, useContext, ReactNode, useReducer, useEffect, useRef } from 'react'; import { createEntity, EntityElement, EntityElementView } from '@/components/diagramview/diagram-elements/EntityElement'; +import { DiagramEventDispatcher } from '@/components/diagramview/events/DiagramEvents'; interface DiagramActions { setZoom: (zoom: number) => void; @@ -13,6 +14,8 @@ interface DiagramActions { setLoadedDiagram: (filename: string | null, source: 'cloud' | 'file' | null, filePath?: string | null) => void; clearDiagram: () => void; setDiagramName: (name: string) => void; + selectEntity: (entityId: string, ctrlClick?: boolean) => void; + clearSelection: () => void; } export interface DiagramState extends DiagramActions { @@ -25,6 +28,7 @@ export interface DiagramState extends DiagramActions { loadedDiagramFilePath: string | null; hasLoadedDiagram: boolean; diagramName: string; + selectedEntities: string[]; } const initialState: DiagramState = { @@ -37,6 +41,7 @@ const initialState: DiagramState = { loadedDiagramFilePath: null, hasLoadedDiagram: false, diagramName: 'untitled', + selectedEntities: [], setZoom: () => { throw new Error("setZoom not initialized yet!"); }, setIsPanning: () => { throw new Error("setIsPanning not initialized yet!"); }, @@ -48,6 +53,8 @@ const initialState: DiagramState = { setLoadedDiagram: () => { throw new Error("setLoadedDiagram not initialized yet!"); }, clearDiagram: () => { throw new Error("clearDiagram not initialized yet!"); }, setDiagramName: () => { throw new Error("setDiagramName not initialized yet!"); }, + selectEntity: () => { throw new Error("selectEntity not initialized yet!"); }, + clearSelection: () => { throw new Error("clearSelection not initialized yet!"); }, } type DiagramViewAction = @@ -56,7 +63,10 @@ type DiagramViewAction = | { type: 'SET_TRANSLATE', payload: { x: number; y: number } } | { type: 'SET_LOADED_DIAGRAM', payload: { filename: string | null; source: 'cloud' | 'file' | null; filePath?: string | null } } | { type: 'CLEAR_DIAGRAM' } - | { type: 'SET_DIAGRAM_NAME', payload: string }; + | { type: 'SET_DIAGRAM_NAME', payload: string } + | { type: 'SELECT_ENTITY', payload: { entityId: string; multiSelect: boolean } } + | { type: 'CLEAR_SELECTION' } + | { type: 'SET_SELECTION', payload: string[] }; const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): DiagramState => { switch (action.type) { @@ -86,6 +96,28 @@ const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): Dia } case 'SET_DIAGRAM_NAME': return { ...state, diagramName: action.payload } + case 'SELECT_ENTITY': + const { entityId, multiSelect } = action.payload; + if (multiSelect) { + // Ctrl+click: toggle the entity in selection + const currentSelection = [...state.selectedEntities]; + const index = currentSelection.indexOf(entityId); + if (index >= 0) { + // Remove from selection (ctrl+click on selected entity) + currentSelection.splice(index, 1); + } else { + // Add to selection (ctrl+click on unselected entity) + currentSelection.push(entityId); + } + return { ...state, selectedEntities: currentSelection }; + } else { + // Regular click: replace selection with single entity + return { ...state, selectedEntities: [entityId] }; + } + case 'CLEAR_SELECTION': + return { ...state, selectedEntities: [] } + case 'SET_SELECTION': + return { ...state, selectedEntities: action.payload } default: return state; } @@ -129,12 +161,22 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { const graphRef = useRef(null); const paperRef = useRef(null); + // Selection collection for coordinated multi-entity operations + // This Collection is updated synchronously whenever selections change, + // enabling reliable multi-selection dragging without timing issues + const selectionCollectionRef = useRef(null); + + + useEffect(() => { if (!diagramViewState.canvas.current) return; const graph = new dia.Graph({}, { cellNamespace: shapes }); graphRef.current = graph; + // Initialize selection collection + selectionCollectionRef.current = new mvc.Collection(); + // Theme-aware colors using MUI CSS variables const gridMinorColor = "var(--mui-palette-border-main)"; const gridMajorColor = "var(--mui-palette-border-main)"; @@ -155,7 +197,9 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { background: { color: backgroundColor }, - interactive: true, + interactive: { + elementMove: false + }, snapToGrid: true, frozen: true, async: true, @@ -165,14 +209,151 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { paperRef.current = paper; diagramViewState.canvas.current.appendChild(paper.el); - // Variables for panning and zooming + // Multi-entity drag handling + let isDraggingSelection = false; + const selectionCollection = selectionCollectionRef.current!; + + // Handle element pointerdown to start potential multi-selection drag + paper.on('element:pointerdown', (elementView: any, evt: any) => { + const entityId = String(elementView.model.id); + + // Check if this entity is in the selection collection + const isInCollection = selectionCollection.get(entityId) !== undefined; + const isMultiSelect = isInCollection && selectionCollection.length > 1; + + + if (isMultiSelect && !evt.ctrlKey) { + // Multi-selection drag - only for non-ctrl clicks to allow ctrl+click deselection + evt.stopPropagation(); + evt.preventDefault(); + + isDraggingSelection = true; + + // Store initial positions for all entities in the collection + const initialPositions = new Map(); + selectionCollection.each((cell: any) => { + const position = cell.get('position'); + initialPositions.set(cell.id, { x: position.x, y: position.y }); + }); + + const startX = evt.clientX; + const startY = evt.clientY; + + // Handle drag movement + const handleSelectionDrag = (moveEvt: MouseEvent) => { + if (!isDraggingSelection) return; + + moveEvt.preventDefault(); + + // Calculate deltas + const deltaX = moveEvt.clientX - startX; + const deltaY = moveEvt.clientY - startY; + + // Convert to paper coordinates and snap to grid + const paperDeltaX = deltaX / currentZoom; + const paperDeltaY = deltaY / currentZoom; + + const gridSize = 20; + const snappedDeltaX = Math.round(paperDeltaX / gridSize) * gridSize; + const snappedDeltaY = Math.round(paperDeltaY / gridSize) * gridSize; + + // Move all cells in the selection collection together + selectionCollection.each((cell: any) => { + const initialPos = initialPositions.get(cell.id); + if (initialPos) { + cell.set('position', { + x: initialPos.x + snappedDeltaX, + y: initialPos.y + snappedDeltaY + }); + } + }); + }; + + // Handle drag end + const handleSelectionDragEnd = () => { + if (!isDraggingSelection) return; + + isDraggingSelection = false; + + // Get entity IDs from collection + const draggedEntityIds: string[] = []; + selectionCollection.each((cell: any) => { + draggedEntityIds.push(String(cell.id)); + }); + + // Notify about drag end + DiagramEventDispatcher.dispatch('entityDragEnd', { + entityIds: draggedEntityIds + }); + + // Clean up event listeners + document.removeEventListener('mousemove', handleSelectionDrag); + document.removeEventListener('mouseup', handleSelectionDragEnd); + }; + + // Add event listeners for this drag operation + document.addEventListener('mousemove', handleSelectionDrag); + document.addEventListener('mouseup', handleSelectionDragEnd); + } else if (!evt.ctrlKey) { + // Single entity drag - only for non-ctrl clicks to allow ctrl+click selection + evt.stopPropagation(); + evt.preventDefault(); + + const cell = elementView.model; + const startX = evt.clientX; + const startY = evt.clientY; + const initialPosition = cell.get('position'); + + // Handle single entity drag + const handleSingleDrag = (moveEvt: MouseEvent) => { + moveEvt.preventDefault(); + + // Calculate deltas + const deltaX = moveEvt.clientX - startX; + const deltaY = moveEvt.clientY - startY; + + // Convert to paper coordinates and snap to grid + const paperDeltaX = deltaX / currentZoom; + const paperDeltaY = deltaY / currentZoom; + + const gridSize = 20; + const snappedDeltaX = Math.round(paperDeltaX / gridSize) * gridSize; + const snappedDeltaY = Math.round(paperDeltaY / gridSize) * gridSize; + + // Move the single entity + cell.set('position', { + x: initialPosition.x + snappedDeltaX, + y: initialPosition.y + snappedDeltaY + }); + }; + + // Handle single drag end + const handleSingleDragEnd = () => { + // Clean up event listeners + document.removeEventListener('mousemove', handleSingleDrag); + document.removeEventListener('mouseup', handleSingleDragEnd); + }; + + // Add event listeners for single entity drag + document.addEventListener('mousemove', handleSingleDrag); + document.addEventListener('mouseup', handleSingleDragEnd); + } else { + // Ctrl+click - let the event pass through to the EntityElement click handler + } + }); + + // Variables for panning, zooming and selection let isPanning = false; + let isSelecting = false; let panStartX = 0; let panStartY = 0; + let selectionStartX = 0; + let selectionStartY = 0; + let selectionRect: HTMLDivElement | null = null; let currentZoom = diagramViewState.zoom; let currentTranslate = { ...diagramViewState.translate }; - // Mouse down handler for panning + // Mouse down handler for panning and selection const handleMouseDown = (evt: MouseEvent) => { if (evt.ctrlKey) { evt.preventDefault(); @@ -181,10 +362,40 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { panStartY = evt.clientY; setIsPanning(true); diagramViewState.canvas.current!.style.cursor = 'grabbing'; + } else { + // Check if click is on empty space (not on an entity) + const elementFromPoint = document.elementFromPoint(evt.clientX, evt.clientY); + const isOnEntity = elementFromPoint?.closest('.entity-container'); + + if (!isOnEntity) { + // Start drag selection + evt.preventDefault(); + isSelecting = true; + selectionStartX = evt.clientX; + selectionStartY = evt.clientY; + + // Clear current selection if not holding Ctrl + if (!evt.ctrlKey) { + clearSelection(); + } + + // Create selection rectangle + selectionRect = document.createElement('div'); + selectionRect.style.position = 'fixed'; + selectionRect.style.border = '2px dashed var(--mui-palette-primary-main)'; + selectionRect.style.backgroundColor = 'var(--mui-palette-action-selected)'; + selectionRect.style.pointerEvents = 'none'; + selectionRect.style.zIndex = '1000'; + selectionRect.style.left = evt.clientX + 'px'; + selectionRect.style.top = evt.clientY + 'px'; + selectionRect.style.width = '0px'; + selectionRect.style.height = '0px'; + document.body.appendChild(selectionRect); + } } }; - // Mouse move handler for panning + // Mouse move handler for panning, selection and dragging const handleMouseMove = (evt: MouseEvent) => { if (isPanning && evt.ctrlKey) { evt.preventDefault(); @@ -210,16 +421,91 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { panStartX = evt.clientX; panStartY = evt.clientY; + } else if (isSelecting && selectionRect) { + evt.preventDefault(); + + // Update selection rectangle + const left = Math.min(selectionStartX, evt.clientX); + const top = Math.min(selectionStartY, evt.clientY); + const width = Math.abs(evt.clientX - selectionStartX); + const height = Math.abs(evt.clientY - selectionStartY); + + selectionRect.style.left = left + 'px'; + selectionRect.style.top = top + 'px'; + selectionRect.style.width = width + 'px'; + selectionRect.style.height = height + 'px'; } }; - // Mouse up handler for panning + // Mouse up handler for panning and selection const handleMouseUp = (evt: MouseEvent) => { if (isPanning) { evt.preventDefault(); isPanning = false; setIsPanning(false); diagramViewState.canvas.current!.style.cursor = 'default'; + } else if (isSelecting && selectionRect) { + evt.preventDefault(); + isSelecting = false; + + // Get selection rectangle bounds + const rect = selectionRect.getBoundingClientRect(); + + // Find entities within selection rectangle + const canvasRect = diagramViewState.canvas.current!.getBoundingClientRect(); + const allEntities = graph.getCells().filter(cell => cell.get('type') === 'diagram.EntityElement'); + const selectedEntities: string[] = []; + + allEntities.forEach(entity => { + const view = paper.findViewByModel(entity); + if (view) { + const entityRect = view.el.getBoundingClientRect(); + + // Check if entity intersects with selection rectangle + const intersects = !( + entityRect.right < rect.left || + entityRect.left > rect.right || + entityRect.bottom < rect.top || + entityRect.top > rect.bottom + ); + + if (intersects) { + selectedEntities.push(String(entity.id)); + } + } + }); + + // Update selection + if (selectedEntities.length > 0) { + if (evt.ctrlKey) { + // Add to existing selection by toggling each entity + selectedEntities.forEach(entityId => { + selectEntity(entityId, true); + }); + } else { + // Set new selection + dispatch({ type: 'SET_SELECTION', payload: selectedEntities }); + + // Update selection collection immediately + if (selectionCollectionRef.current && graphRef.current) { + const selectedCells = selectedEntities.map(id => graphRef.current!.getCell(id)).filter(Boolean); + selectionCollectionRef.current.reset(selectedCells); + } + + // Update visual state + allEntities.forEach(entity => { + const isSelected = selectedEntities.includes(String(entity.id)); + entity.attr('container/style/border', isSelected ? + '2px solid var(--mui-palette-secondary-main)' : + '2px solid var(--mui-palette-primary-main)' + ); + }); + } + } + + // Remove selection rectangle + document.body.removeChild(selectionRect); + selectionRect = null; } }; @@ -294,6 +580,20 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { } }; + // Key handler for canceling selection + const handleKeyDown = (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + // Cancel selection + if (isSelecting && selectionRect) { + isSelecting = false; + document.body.removeChild(selectionRect); + selectionRect = null; + } + // Clear current selection + clearSelection(); + } + }; + // Add event listeners @@ -301,6 +601,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { canvas.addEventListener('mousedown', handleMouseDown); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('keydown', handleKeyDown); canvas.addEventListener('wheel', handleWheel, { passive: false }); // Unfreeze and render the paper to make it interactive @@ -312,7 +613,14 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { canvas.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('keydown', handleKeyDown); canvas.removeEventListener('wheel', handleWheel); + + // Clean up any remaining selection rectangle + if (selectionRect && selectionRect.parentNode) { + document.body.removeChild(selectionRect); + } + paper.remove(); }; }, []); @@ -387,8 +695,80 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { } }; + const selectEntity = (entityId: string, ctrlClick: boolean = false) => { + // Calculate the new selection state first + let newSelectedEntities; + if (ctrlClick) { + const currentSelection = [...diagramViewState.selectedEntities]; + const index = currentSelection.indexOf(entityId); + if (index >= 0) { + currentSelection.splice(index, 1); + } else { + currentSelection.push(entityId); + } + newSelectedEntities = currentSelection; + } else { + newSelectedEntities = [entityId]; + } + + if (graphRef.current) { + const allEntities = graphRef.current.getCells().filter(cell => cell.get('type') === 'diagram.EntityElement'); + + if (ctrlClick) { + // Ctrl+click: toggle the entity in selection - use calculated new state + const willBeSelected = newSelectedEntities.includes(entityId); + + const entity = graphRef.current.getCell(entityId); + if (entity) { + const borderColor = willBeSelected ? + '2px solid var(--mui-palette-secondary-main)' : + '2px solid var(--mui-palette-primary-main)'; + entity.attr('container/style/border', borderColor); + } + } else { + // Regular click: clear all selections visually first, then select this one + allEntities.forEach(entity => { + entity.attr('container/style/border', '2px solid var(--mui-palette-primary-main)'); + }); + + const entity = graphRef.current.getCell(entityId); + if (entity) { + entity.attr('container/style/border', '2px solid var(--mui-palette-secondary-main)'); + } + } + } + + // Update state + dispatch({ type: 'SELECT_ENTITY', payload: { entityId, multiSelect: ctrlClick } }); + + // Update collection with the new selection immediately + if (selectionCollectionRef.current && graphRef.current) { + const selectedCells = newSelectedEntities.map(id => graphRef.current!.getCell(id)).filter(Boolean); + selectionCollectionRef.current.reset(selectedCells); + } + }; + + const clearSelection = () => { + dispatch({ type: 'CLEAR_SELECTION' }); + + // Clear visual selection state on all entities + if (graphRef.current) { + const allEntities = graphRef.current.getCells().filter(cell => cell.get('type') === 'diagram.EntityElement'); + allEntities.forEach(entity => { + entity.attr('container/style/border', '2px solid var(--mui-palette-primary-main)'); + }); + } + + // Update selection collection immediately - clear it + if (selectionCollectionRef.current) { + selectionCollectionRef.current.reset([]); + } + }; + + + return ( - + {children} From 7f99864352b1507309764f7bb1a3fcf0a23c5454 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 21 Oct 2025 21:27:38 +0200 Subject: [PATCH 29/51] feat: selection via paper embeddings and custom class for it --- .../diagramview/DiagramContainer.tsx | 18 +- .../diagram-elements/EntityElement.ts | 76 ++--- .../diagramview/diagram-elements/Selection.ts | 201 ++++++++++++ .../diagramview/events/DiagramEvents.ts | 73 ----- Website/contexts/DiagramViewContext.tsx | 297 +----------------- 5 files changed, 242 insertions(+), 423 deletions(-) create mode 100644 Website/components/diagramview/diagram-elements/Selection.ts delete mode 100644 Website/components/diagramview/events/DiagramEvents.ts diff --git a/Website/components/diagramview/DiagramContainer.tsx b/Website/components/diagramview/DiagramContainer.tsx index 015888c..027799a 100644 --- a/Website/components/diagramview/DiagramContainer.tsx +++ b/Website/components/diagramview/DiagramContainer.tsx @@ -4,14 +4,13 @@ import React, { useState, useEffect } from "react"; import { Box } from "@mui/material"; import { useDiagramView } from "@/contexts/DiagramViewContext"; import { EntityContextMenu } from "./smaller-components/EntityContextMenu"; -import { EntityContextMenuEvent, EntitySelectEvent, DiagramEventDispatcher } from "./events/DiagramEvents"; interface IDiagramContainerProps { } export default function DiagramContainer({ }: IDiagramContainerProps) { - const { canvas, selectEntity } = useDiagramView(); + const { canvas } = useDiagramView(); const [contextMenu, setContextMenu] = useState<{ open: boolean; position: { top: number; left: number } | null; @@ -22,7 +21,7 @@ export default function DiagramContainer({ }: IDiagramContainerProps) { }); useEffect(() => { - const handleEntityContextMenu = (evt: EntityContextMenuEvent) => { + const handleEntityContextMenu = (evt: CustomEvent) => { const { entityId, x, y } = evt.detail; setContextMenu({ open: true, @@ -31,19 +30,12 @@ export default function DiagramContainer({ }: IDiagramContainerProps) { }); }; - const handleEntitySelect = (evt: EntitySelectEvent) => { - const { entityId, ctrlKey } = evt.detail; - selectEntity(entityId, ctrlKey); - }; - - DiagramEventDispatcher.addEventListener('entityContextMenu', handleEntityContextMenu); - DiagramEventDispatcher.addEventListener('entitySelect', handleEntitySelect); + window.addEventListener('entityContextMenu', handleEntityContextMenu as EventListener); return () => { - DiagramEventDispatcher.removeEventListener('entityContextMenu', handleEntityContextMenu); - DiagramEventDispatcher.removeEventListener('entitySelect', handleEntitySelect); + window.removeEventListener('entityContextMenu', handleEntityContextMenu as EventListener); }; - }, [selectEntity]); + }, []); const handleCloseContextMenu = () => { setContextMenu({ diff --git a/Website/components/diagramview/diagram-elements/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts index 980b24a..bb27f81 100644 --- a/Website/components/diagramview/diagram-elements/EntityElement.ts +++ b/Website/components/diagramview/diagram-elements/EntityElement.ts @@ -1,11 +1,8 @@ import { dia, util } from '@joint/core'; -import { DiagramEventDispatcher } from '../events/DiagramEvents'; export const EntityElementView = dia.ElementView.extend({ events: { - 'click': 'onEntityClick', - 'dblclick': 'onTitleDoubleClick', 'mouseenter': 'onMouseEnter', 'mouseleave': 'onMouseLeave', 'contextmenu': 'onContextMenu' @@ -13,71 +10,58 @@ export const EntityElementView = dia.ElementView.extend({ initialize: function(options?: any) { dia.ElementView.prototype.initialize.call(this, options); - this.listenTo(this.model, 'change:label', this.updateTitle); this.updateTitle(); - // Listen for drag end events to mark this entity as having been dragged - this._dragEndHandler = (evt: CustomEvent) => { - const { entityIds } = evt.detail; - if (entityIds.includes(String(this.model.id))) { - this._wasDragged = true; - } - }; - window.addEventListener('entityDragEnd', this._dragEndHandler as EventListener); - }, - - remove: function() { - if (this._dragEndHandler) { - window.removeEventListener('entityDragEnd', this._dragEndHandler as EventListener); - } - dia.ElementView.prototype.remove.call(this); - }, - - onEntityClick: function(evt: MouseEvent) { - // Don't select if this was part of a drag operation (otherwise click would fire post drag, meaning the selection would just be this entity) - if (this._wasDragged) { - this._wasDragged = false; - return; - } - - this.preventDefaultInteraction(evt); - - DiagramEventDispatcher.dispatch('entitySelect', { - entityId: String(this.model.id), - ctrlKey: evt.ctrlKey - }); - }, - - onTitleDoubleClick: function(evt: Event) { - evt.preventDefault(); - evt.stopPropagation(); + // Drag state + this.isDragging = false; + this.dragStartPosition = null; + this.initialPosition = null; + this.dragMoveHandler = null; + this.dragEndHandler = null; }, onMouseEnter: function() { + // Change cursor and highlight entity this.model.attr('container/style/cursor', 'move'); + this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-default)'); }, onMouseLeave: function() { + // Change cursor back and remove highlight this.model.attr('container/style/cursor', 'default'); + this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-paper)'); }, onContextMenu: function(evt: MouseEvent) { evt.preventDefault(); evt.stopPropagation(); - DiagramEventDispatcher.dispatch('entityContextMenu', { - entityId: String(this.model.id), - x: evt.clientX, - y: evt.clientY + // Dispatch a custom event for context menu + const contextMenuEvent = new CustomEvent('entityContextMenu', { + detail: { + entityId: String(this.model.id), + x: evt.clientX, + y: evt.clientY + } }); + window.dispatchEvent(contextMenuEvent); }, - - updateTitle: function() { const label = this.model.get('label') || 'Entity'; this.model.attr('title/html', label); }, + + remove: function() { + // Clean up any remaining event listeners + if (this.dragMoveHandler) { + document.removeEventListener('mousemove', this.dragMoveHandler); + } + if (this.dragEndHandler) { + document.removeEventListener('mouseup', this.dragEndHandler); + } + dia.ElementView.prototype.remove.call(this); + } }); export const EntityElement = dia.Element.define('diagram.EntityElement', { @@ -119,7 +103,7 @@ export const EntityElement = dia.Element.define('diagram.EntityElement', {
diff --git a/Website/components/diagramview/diagram-elements/Selection.ts b/Website/components/diagramview/diagram-elements/Selection.ts new file mode 100644 index 0000000..b33d3c5 --- /dev/null +++ b/Website/components/diagramview/diagram-elements/Selection.ts @@ -0,0 +1,201 @@ +import { dia, g, mvc, V, VElement } from "@joint/core"; + +export const SelectionElement = dia.Element.define('selection.SelectionElement', { + size: { width: 100, height: 100 }, + attrs: { + body: { + refWidth: '100%', + refHeight: '100%', + stroke: '#2F80ED', + strokeWidth: 1, + strokeDasharray: '4 2', + fill: 'rgba(47,128,237,0.06)', + rx: 4, ry: 4 + } + } + }, { + markup: [{ + tagName: 'rect', + selector: 'body' + }] +}); + +export default class EntitySelection { + private elements: mvc.Collection; + private paper: dia.Paper; + private graph: dia.Graph; + + // transient drag state + private isDragging: boolean = false; + private dragStart: g.Point | null = null; + private overlayRect: VElement | null = null; + private selectionElement: dia.Element | null = null; + + constructor(paper: dia.Paper) { + this.elements = new mvc.Collection(); + this.paper = paper; + this.graph = paper.model as dia.Graph; + + paper.on('blank:pointerdown', this.handleSelectionStart); + paper.on('blank:pointermove', this.handleAreaSelection); + paper.on('blank:pointerup', this.handleSelectionEnd); + } + + private handleSelectionStart = (_evt: dia.Event, x: number, y: number) => { + this.isDragging = true; + this.dragStart = new g.Point(x, y); + + // Create transient overlay rect directly in the paper’s SVG layer + const svgRoot = this.paper.svg as unknown as SVGSVGElement; + const rect = V('rect', { + x, y, + width: 1, + height: 1, + 'pointer-events': 'none', + stroke: '#2F80ED', + 'stroke-width': 1, + 'stroke-dasharray': '4 2', + fill: 'rgba(47,128,237,0.10)', + rx: 2, ry: 2 + }); + + V(svgRoot).append(rect); + this.overlayRect = rect; + }; + + private handleAreaSelection = (_evt: dia.Event, x: number, y: number) => { + if (!this.isDragging || !this.dragStart || !this.overlayRect) return; + + const p0 = this.dragStart; + const p1 = new g.Point(x, y); + + const minX = Math.min(p0.x, p1.x); + const minY = Math.min(p0.y, p1.y); + const width = Math.max(1, Math.abs(p1.x - p0.x)); + const height = Math.max(1, Math.abs(p1.y - p0.y)); + + this.overlayRect.attr({ + x: minX, y: minY, width, height + }); + }; + + private handleSelectionEnd = (_evt: dia.Event, x: number, y: number) => { + if (!this.isDragging || !this.dragStart) { + this.cleanupOverlay(); + return; + } + + this.isDragging = false; + + const p0 = this.dragStart; + const p1 = new g.Point(x, y); + const selRect = new g.Rect( + Math.min(p0.x, p1.x), + Math.min(p0.y, p1.y), + Math.abs(p1.x - p0.x), + Math.abs(p1.y - p0.y) + ); + + this.cleanupOverlay(); + + // Ignore tiny clicks (treat as no selection) + if (selRect.width < 3 && selRect.height < 3) return; + + // Collect fully-inside elements (exclude links & the previous selection element) + const inside: dia.Element[] = []; + for (const cell of this.graph.getCells()) { + if (!cell.isElement()) continue; + if (cell.get('type') === 'selection.SelectionElement') continue; + + // Use model geometry BBox to avoid stroke inflation + const bbox = (cell as dia.Element).getBBox({ useModelGeometry: true }); + if (selRect.containsRect(bbox)) { + inside.push(cell as dia.Element); + this.paper.findViewByModel(cell).setInteractivity({ stopDelegation: false }); + } + } + + // Clear previous collection and remember current + this.elements.reset(inside); + + // If nothing selected, clear selection element + if (inside.length === 0) { + this.teardownSelectionElement(); + return; + } + + // Build a selection container element that wraps the inside bbox + const groupBBox = inside + .map((el) => el.getBBox({ deep: true, useModelGeometry: true })) + .reduce((acc, r) => acc ? acc.union(r) : r, null as g.Rect | null) as g.Rect; + + // Create or update the SelectionElement sized/positioned to the bounding box + if (!this.selectionElement) { + this.selectionElement = new SelectionElement({ + position: { x: groupBBox.x, y: groupBBox.y }, + size: { width: groupBBox.width, height: groupBBox.height } + }); + this.graph.addCell(this.selectionElement); + // Put it behind the children (so you can still click children if needed) + this.selectionElement.toBack(); + } else { + const prev = this.selectionElement.getEmbeddedCells(); + prev.forEach((c) => this.selectionElement!.unembed(c)); + prev.forEach((c) => this.paper.findViewByModel(c)?.setInteractivity(true)); + + this.selectionElement.position(groupBBox.x, groupBBox.y); + this.selectionElement.resize(groupBBox.width, groupBBox.height); + // Ensure it’s behind again (in case z-order changed) + this.selectionElement.toBack(); + } + + // (Re)embed selected elements into the selection container + // First, unembed anything previously embedded + const prev = this.selectionElement.getEmbeddedCells(); + prev.forEach((c) => this.selectionElement!.unembed(c)); + + inside.forEach((el) => this.selectionElement!.embed(el)); + + // Optional visual affordance when active + this.selectionElement.attr(['body', 'stroke'], '#2F80ED'); + }; + + // --- Helpers --------------------------------------------------------------- + + private teardownSelectionElement() { + if (!this.selectionElement) return; + + // Unembed and restore interactivity on kids + const kids = this.selectionElement.getEmbeddedCells(); + for (const k of kids) { + this.selectionElement.unembed(k); + this.paper.findViewByModel(k)?.setInteractivity(true); + } + + // Now it's safe to remove just the container + this.selectionElement.remove(); // no embedded children to take with it + this.selectionElement = null; + } + + private cleanupOverlay() { + if (this.overlayRect) { + this.overlayRect.remove(); + this.overlayRect = null; + } + this.dragStart = null; + } + + // Public API (optional): get selected elements + public getSelected(): dia.Element[] { + return this.elements.toArray(); + } + + // Public API (optional): clear selection + public clear() { + this.elements.reset([]); + if (this.selectionElement) { + this.selectionElement.remove(); + this.selectionElement = null; + } + } +} \ No newline at end of file diff --git a/Website/components/diagramview/events/DiagramEvents.ts b/Website/components/diagramview/events/DiagramEvents.ts deleted file mode 100644 index d03112f..0000000 --- a/Website/components/diagramview/events/DiagramEvents.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Type definitions for diagram events -export interface EntitySelectEventDetail { - entityId: string; - ctrlKey: boolean; -} - -export interface EntityContextMenuEventDetail { - entityId: string; - x: number; - y: number; -} - -export interface EntityDragStartEventDetail { - entityId: string; - startX: number; - startY: number; -} - -export interface EntityDragEndEventDetail { - entityIds: string[]; -} - -// Custom event type definitions -export interface EntitySelectEvent extends CustomEvent { - detail: EntitySelectEventDetail; -} - -export interface EntityContextMenuEvent extends CustomEvent { - detail: EntityContextMenuEventDetail; -} - -export interface EntityDragStartEvent extends CustomEvent { - detail: EntityDragStartEventDetail; -} - -export interface EntityDragEndEvent extends CustomEvent { - detail: EntityDragEndEventDetail; -} - -// Event type map for type safety -export interface DiagramEventMap { - 'entitySelect': EntitySelectEvent; - 'entityContextMenu': EntityContextMenuEvent; - 'entityDragStart': EntityDragStartEvent; - 'entityDragEnd': EntityDragEndEvent; -} - -// Type-safe event dispatcher class -export class DiagramEventDispatcher { - static dispatch( - type: K, - detail: DiagramEventMap[K]['detail'] - ): void { - const event = new CustomEvent(type, { detail }) as DiagramEventMap[K]; - window.dispatchEvent(event); - } - - static addEventListener( - type: K, - listener: (event: DiagramEventMap[K]) => void, - options?: boolean | AddEventListenerOptions - ): void { - window.addEventListener(type, listener as EventListener, options); - } - - static removeEventListener( - type: K, - listener: (event: DiagramEventMap[K]) => void, - options?: boolean | EventListenerOptions - ): void { - window.removeEventListener(type, listener as EventListener, options); - } -} \ No newline at end of file diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index a201e6e..673e0c2 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -1,7 +1,7 @@ import { dia, shapes, mvc } from '@joint/core'; import React, { createContext, useContext, ReactNode, useReducer, useEffect, useRef } from 'react'; import { createEntity, EntityElement, EntityElementView } from '@/components/diagramview/diagram-elements/EntityElement'; -import { DiagramEventDispatcher } from '@/components/diagramview/events/DiagramEvents'; +import EntitySelection, { SelectionElement } from '@/components/diagramview/diagram-elements/Selection'; interface DiagramActions { setZoom: (zoom: number) => void; @@ -160,23 +160,13 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { // Refs to store graph and paper instances const graphRef = useRef(null); const paperRef = useRef(null); - - // Selection collection for coordinated multi-entity operations - // This Collection is updated synchronously whenever selections change, - // enabling reliable multi-selection dragging without timing issues - const selectionCollectionRef = useRef(null); - - - + useEffect(() => { if (!diagramViewState.canvas.current) return; const graph = new dia.Graph({}, { cellNamespace: shapes }); graphRef.current = graph; - // Initialize selection collection - selectionCollectionRef.current = new mvc.Collection(); - // Theme-aware colors using MUI CSS variables const gridMinorColor = "var(--mui-palette-border-main)"; const gridMajorColor = "var(--mui-palette-border-main)"; @@ -198,158 +188,24 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { color: backgroundColor }, interactive: { - elementMove: false + elementMove: true }, snapToGrid: true, frozen: true, async: true, - cellViewNamespace: { ...shapes, diagram: { EntityElement, EntityElementView } } + cellViewNamespace: { ...shapes, diagram: { EntityElement, EntityElementView }, selection: { SelectionElement } } }); paperRef.current = paper; diagramViewState.canvas.current.appendChild(paper.el); - - // Multi-entity drag handling - let isDraggingSelection = false; - const selectionCollection = selectionCollectionRef.current!; - - // Handle element pointerdown to start potential multi-selection drag - paper.on('element:pointerdown', (elementView: any, evt: any) => { - const entityId = String(elementView.model.id); - - // Check if this entity is in the selection collection - const isInCollection = selectionCollection.get(entityId) !== undefined; - const isMultiSelect = isInCollection && selectionCollection.length > 1; - - if (isMultiSelect && !evt.ctrlKey) { - // Multi-selection drag - only for non-ctrl clicks to allow ctrl+click deselection - evt.stopPropagation(); - evt.preventDefault(); - - isDraggingSelection = true; - - // Store initial positions for all entities in the collection - const initialPositions = new Map(); - selectionCollection.each((cell: any) => { - const position = cell.get('position'); - initialPositions.set(cell.id, { x: position.x, y: position.y }); - }); - - const startX = evt.clientX; - const startY = evt.clientY; - - // Handle drag movement - const handleSelectionDrag = (moveEvt: MouseEvent) => { - if (!isDraggingSelection) return; - - moveEvt.preventDefault(); - - // Calculate deltas - const deltaX = moveEvt.clientX - startX; - const deltaY = moveEvt.clientY - startY; - - // Convert to paper coordinates and snap to grid - const paperDeltaX = deltaX / currentZoom; - const paperDeltaY = deltaY / currentZoom; - - const gridSize = 20; - const snappedDeltaX = Math.round(paperDeltaX / gridSize) * gridSize; - const snappedDeltaY = Math.round(paperDeltaY / gridSize) * gridSize; - - // Move all cells in the selection collection together - selectionCollection.each((cell: any) => { - const initialPos = initialPositions.get(cell.id); - if (initialPos) { - cell.set('position', { - x: initialPos.x + snappedDeltaX, - y: initialPos.y + snappedDeltaY - }); - } - }); - }; - - // Handle drag end - const handleSelectionDragEnd = () => { - if (!isDraggingSelection) return; - - isDraggingSelection = false; - - // Get entity IDs from collection - const draggedEntityIds: string[] = []; - selectionCollection.each((cell: any) => { - draggedEntityIds.push(String(cell.id)); - }); - - // Notify about drag end - DiagramEventDispatcher.dispatch('entityDragEnd', { - entityIds: draggedEntityIds - }); - - // Clean up event listeners - document.removeEventListener('mousemove', handleSelectionDrag); - document.removeEventListener('mouseup', handleSelectionDragEnd); - }; - - // Add event listeners for this drag operation - document.addEventListener('mousemove', handleSelectionDrag); - document.addEventListener('mouseup', handleSelectionDragEnd); - } else if (!evt.ctrlKey) { - // Single entity drag - only for non-ctrl clicks to allow ctrl+click selection - evt.stopPropagation(); - evt.preventDefault(); - - const cell = elementView.model; - const startX = evt.clientX; - const startY = evt.clientY; - const initialPosition = cell.get('position'); - - // Handle single entity drag - const handleSingleDrag = (moveEvt: MouseEvent) => { - moveEvt.preventDefault(); - - // Calculate deltas - const deltaX = moveEvt.clientX - startX; - const deltaY = moveEvt.clientY - startY; - - // Convert to paper coordinates and snap to grid - const paperDeltaX = deltaX / currentZoom; - const paperDeltaY = deltaY / currentZoom; - - const gridSize = 20; - const snappedDeltaX = Math.round(paperDeltaX / gridSize) * gridSize; - const snappedDeltaY = Math.round(paperDeltaY / gridSize) * gridSize; - - // Move the single entity - cell.set('position', { - x: initialPosition.x + snappedDeltaX, - y: initialPosition.y + snappedDeltaY - }); - }; - - // Handle single drag end - const handleSingleDragEnd = () => { - // Clean up event listeners - document.removeEventListener('mousemove', handleSingleDrag); - document.removeEventListener('mouseup', handleSingleDragEnd); - }; - - // Add event listeners for single entity drag - document.addEventListener('mousemove', handleSingleDrag); - document.addEventListener('mouseup', handleSingleDragEnd); - } else { - // Ctrl+click - let the event pass through to the EntityElement click handler - } - }); + const selection = new EntitySelection(paper); + // Update all entity views with selection callbacks when entities are added // Variables for panning, zooming and selection let isPanning = false; - let isSelecting = false; let panStartX = 0; let panStartY = 0; - let selectionStartX = 0; - let selectionStartY = 0; - let selectionRect: HTMLDivElement | null = null; let currentZoom = diagramViewState.zoom; let currentTranslate = { ...diagramViewState.translate }; @@ -362,36 +218,6 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { panStartY = evt.clientY; setIsPanning(true); diagramViewState.canvas.current!.style.cursor = 'grabbing'; - } else { - // Check if click is on empty space (not on an entity) - const elementFromPoint = document.elementFromPoint(evt.clientX, evt.clientY); - const isOnEntity = elementFromPoint?.closest('.entity-container'); - - if (!isOnEntity) { - // Start drag selection - evt.preventDefault(); - isSelecting = true; - selectionStartX = evt.clientX; - selectionStartY = evt.clientY; - - // Clear current selection if not holding Ctrl - if (!evt.ctrlKey) { - clearSelection(); - } - - // Create selection rectangle - selectionRect = document.createElement('div'); - selectionRect.style.position = 'fixed'; - selectionRect.style.border = '2px dashed var(--mui-palette-primary-main)'; - selectionRect.style.backgroundColor = 'var(--mui-palette-action-selected)'; - selectionRect.style.pointerEvents = 'none'; - selectionRect.style.zIndex = '1000'; - selectionRect.style.left = evt.clientX + 'px'; - selectionRect.style.top = evt.clientY + 'px'; - selectionRect.style.width = '0px'; - selectionRect.style.height = '0px'; - document.body.appendChild(selectionRect); - } } }; @@ -421,19 +247,6 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { panStartX = evt.clientX; panStartY = evt.clientY; - } else if (isSelecting && selectionRect) { - evt.preventDefault(); - - // Update selection rectangle - const left = Math.min(selectionStartX, evt.clientX); - const top = Math.min(selectionStartY, evt.clientY); - const width = Math.abs(evt.clientX - selectionStartX); - const height = Math.abs(evt.clientY - selectionStartY); - - selectionRect.style.left = left + 'px'; - selectionRect.style.top = top + 'px'; - selectionRect.style.width = width + 'px'; - selectionRect.style.height = height + 'px'; } }; @@ -444,68 +257,6 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { isPanning = false; setIsPanning(false); diagramViewState.canvas.current!.style.cursor = 'default'; - } else if (isSelecting && selectionRect) { - evt.preventDefault(); - isSelecting = false; - - // Get selection rectangle bounds - const rect = selectionRect.getBoundingClientRect(); - - // Find entities within selection rectangle - const canvasRect = diagramViewState.canvas.current!.getBoundingClientRect(); - const allEntities = graph.getCells().filter(cell => cell.get('type') === 'diagram.EntityElement'); - const selectedEntities: string[] = []; - - allEntities.forEach(entity => { - const view = paper.findViewByModel(entity); - if (view) { - const entityRect = view.el.getBoundingClientRect(); - - // Check if entity intersects with selection rectangle - const intersects = !( - entityRect.right < rect.left || - entityRect.left > rect.right || - entityRect.bottom < rect.top || - entityRect.top > rect.bottom - ); - - if (intersects) { - selectedEntities.push(String(entity.id)); - } - } - }); - - // Update selection - if (selectedEntities.length > 0) { - if (evt.ctrlKey) { - // Add to existing selection by toggling each entity - selectedEntities.forEach(entityId => { - selectEntity(entityId, true); - }); - } else { - // Set new selection - dispatch({ type: 'SET_SELECTION', payload: selectedEntities }); - - // Update selection collection immediately - if (selectionCollectionRef.current && graphRef.current) { - const selectedCells = selectedEntities.map(id => graphRef.current!.getCell(id)).filter(Boolean); - selectionCollectionRef.current.reset(selectedCells); - } - - // Update visual state - allEntities.forEach(entity => { - const isSelected = selectedEntities.includes(String(entity.id)); - entity.attr('container/style/border', isSelected ? - '2px solid var(--mui-palette-secondary-main)' : - '2px solid var(--mui-palette-primary-main)' - ); - }); - } - } - - // Remove selection rectangle - document.body.removeChild(selectionRect); - selectionRect = null; } }; @@ -580,28 +331,11 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { } }; - // Key handler for canceling selection - const handleKeyDown = (evt: KeyboardEvent) => { - if (evt.key === 'Escape') { - // Cancel selection - if (isSelecting && selectionRect) { - isSelecting = false; - document.body.removeChild(selectionRect); - selectionRect = null; - } - // Clear current selection - clearSelection(); - } - }; - - - // Add event listeners const canvas = diagramViewState.canvas.current; canvas.addEventListener('mousedown', handleMouseDown); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); - document.addEventListener('keydown', handleKeyDown); canvas.addEventListener('wheel', handleWheel, { passive: false }); // Unfreeze and render the paper to make it interactive @@ -613,14 +347,8 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { canvas.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); - document.removeEventListener('keydown', handleKeyDown); canvas.removeEventListener('wheel', handleWheel); - // Clean up any remaining selection rectangle - if (selectionRect && selectionRect.parentNode) { - document.body.removeChild(selectionRect); - } - paper.remove(); }; }, []); @@ -740,12 +468,6 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { // Update state dispatch({ type: 'SELECT_ENTITY', payload: { entityId, multiSelect: ctrlClick } }); - - // Update collection with the new selection immediately - if (selectionCollectionRef.current && graphRef.current) { - const selectedCells = newSelectedEntities.map(id => graphRef.current!.getCell(id)).filter(Boolean); - selectionCollectionRef.current.reset(selectedCells); - } }; const clearSelection = () => { @@ -758,15 +480,8 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { entity.attr('container/style/border', '2px solid var(--mui-palette-primary-main)'); }); } - - // Update selection collection immediately - clear it - if (selectionCollectionRef.current) { - selectionCollectionRef.current.reset([]); - } }; - - return ( From b5e594656f8686daca36482cad7a9d7b69fab82e Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 21 Oct 2025 21:32:35 +0200 Subject: [PATCH 30/51] fix: minor bug where old selections would be interactive in second selection --- .../components/diagramview/diagram-elements/Selection.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Website/components/diagramview/diagram-elements/Selection.ts b/Website/components/diagramview/diagram-elements/Selection.ts index b33d3c5..f9e53bb 100644 --- a/Website/components/diagramview/diagram-elements/Selection.ts +++ b/Website/components/diagramview/diagram-elements/Selection.ts @@ -97,6 +97,7 @@ export default class EntitySelection { ); this.cleanupOverlay(); + this.teardownSelectionElement(); // Ignore tiny clicks (treat as no selection) if (selRect.width < 3 && selRect.height < 3) return; @@ -119,7 +120,7 @@ export default class EntitySelection { this.elements.reset(inside); // If nothing selected, clear selection element - if (inside.length === 0) { + if (inside.length === 1) { this.teardownSelectionElement(); return; } @@ -139,10 +140,6 @@ export default class EntitySelection { // Put it behind the children (so you can still click children if needed) this.selectionElement.toBack(); } else { - const prev = this.selectionElement.getEmbeddedCells(); - prev.forEach((c) => this.selectionElement!.unembed(c)); - prev.forEach((c) => this.paper.findViewByModel(c)?.setInteractivity(true)); - this.selectionElement.position(groupBBox.x, groupBBox.y); this.selectionElement.resize(groupBBox.width, groupBBox.height); // Ensure it’s behind again (in case z-order changed) From f289d7a629db198d6f0842c7f97e767ce3b35408 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 22 Oct 2025 19:06:11 +0200 Subject: [PATCH 31/51] feat: Multiple entity selection and entity properties pane --- .../diagramview/DiagramContainer.tsx | 5 +- .../diagramview/PropertiesPanel.tsx | 48 ++++++ .../diagramview/SidebarDiagramView.tsx | 8 +- .../diagram-elements/EntityElement.ts | 75 +++++++-- .../diagramview/diagram-elements/Selection.ts | 21 ++- .../diagramview/events/SelectObjectEvent.ts | 7 + .../diagramview/panes/EntitySelectionPane.tsx | 66 ++++++-- .../diagramview/panes/RelatedEntitiesPane.tsx | 151 ++++++++++++++++++ .../smaller-components/EntityProperties.tsx | 59 +++++++ Website/contexts/DiagramViewContext.tsx | 13 +- Website/lib/icons.tsx | 13 +- 11 files changed, 426 insertions(+), 40 deletions(-) create mode 100644 Website/components/diagramview/PropertiesPanel.tsx create mode 100644 Website/components/diagramview/events/SelectObjectEvent.ts create mode 100644 Website/components/diagramview/panes/RelatedEntitiesPane.tsx create mode 100644 Website/components/diagramview/smaller-components/EntityProperties.tsx diff --git a/Website/components/diagramview/DiagramContainer.tsx b/Website/components/diagramview/DiagramContainer.tsx index 027799a..82eb1cc 100644 --- a/Website/components/diagramview/DiagramContainer.tsx +++ b/Website/components/diagramview/DiagramContainer.tsx @@ -4,6 +4,7 @@ import React, { useState, useEffect } from "react"; import { Box } from "@mui/material"; import { useDiagramView } from "@/contexts/DiagramViewContext"; import { EntityContextMenu } from "./smaller-components/EntityContextMenu"; +import PropertiesPanel from "./PropertiesPanel"; interface IDiagramContainerProps { @@ -50,7 +51,8 @@ export default function DiagramContainer({ }: IDiagramContainerProps) { height: '100%', overflow: 'hidden', display: 'flex', - flexDirection: 'column' + flexDirection: 'column', + position: 'relative' }}>
+ ); } diff --git a/Website/components/diagramview/PropertiesPanel.tsx b/Website/components/diagramview/PropertiesPanel.tsx new file mode 100644 index 0000000..42c6af4 --- /dev/null +++ b/Website/components/diagramview/PropertiesPanel.tsx @@ -0,0 +1,48 @@ +import React, { useEffect, useState } from 'react' +import { SelectObjectEvent } from './events/SelectObjectEvent'; +import { Box, Divider, IconButton } from '@mui/material'; +import { CloseIcon } from '@/lib/icons'; +import EntityProperties from './smaller-components/EntityProperties'; + +interface IPropertiesPanelProps { + +} + +export default function PropertiesPanel({ }: IPropertiesPanelProps) { + const [object, setObject] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + const close = () => { + setIsOpen(false); + setObject(null); + } + + useEffect(() => { + const handleObjectSelection = (evt: CustomEvent) => { + console.log("Object selected:", evt.detail); + setObject(evt.detail); + setIsOpen(true); + }; + window.addEventListener('selectObject', handleObjectSelection as EventListener); + return () => { + window.removeEventListener('selectObject', handleObjectSelection as EventListener); + }; + }, []); + + const getProperties = () => { + switch (object?.type) { + case 'entity': + return ; + } + } + + return ( + + {CloseIcon} + + + {getProperties()} + + + ) +} diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index 96572e9..f033e6e 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -1,4 +1,4 @@ -import { Box, Tooltip, Typography, Grid, TextField, Divider } from '@mui/material'; +import { Box, Tooltip, Typography, Grid, TextField, Divider, Button } from '@mui/material'; import React, { useState } from 'react'; import { AddSquareIcon } from '@/lib/icons'; import { EntitySelectionPane } from './panes/EntitySelectionPane'; @@ -36,7 +36,7 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { { id: 'add-entity', label: 'Add Entity', - icon: AddSquareIcon, + icon: , action: handleAddEntity }, ]; @@ -64,11 +64,11 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { {diagramTools.map((tool) => ( - + {tool.icon} diff --git a/Website/components/diagramview/diagram-elements/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts index bb27f81..225059a 100644 --- a/Website/components/diagramview/diagram-elements/EntityElement.ts +++ b/Website/components/diagramview/diagram-elements/EntityElement.ts @@ -1,35 +1,39 @@ import { dia, util } from '@joint/core'; +import { SelectObjectEvent } from '../events/SelectObjectEvent'; +import { EntityType } from '@/lib/Types'; export const EntityElementView = dia.ElementView.extend({ events: { 'mouseenter': 'onMouseEnter', 'mouseleave': 'onMouseLeave', - 'contextmenu': 'onContextMenu' + 'contextmenu': 'onContextMenu', + 'pointerdown': 'onPointerDown', + 'pointerup': 'onPointerUp', }, initialize: function(options?: any) { dia.ElementView.prototype.initialize.call(this, options); this.updateTitle(); - - // Drag state - this.isDragging = false; - this.dragStartPosition = null; - this.initialPosition = null; - this.dragMoveHandler = null; - this.dragEndHandler = null; + this.isSelected = false; // Track selection state }, onMouseEnter: function() { - // Change cursor and highlight entity - this.model.attr('container/style/cursor', 'move'); - this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-default)'); + // Only apply hover effects if not selected + if (!this.isSelected) { + this.model.attr('container/style/cursor', 'move'); + this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-default)'); + this.model.attr('container/style/borderColor', 'var(--mui-palette-primary-main)'); + } }, onMouseLeave: function() { - // Change cursor back and remove highlight - this.model.attr('container/style/cursor', 'default'); - this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-paper)'); + // Only remove hover effects if not selected + if (!this.isSelected) { + this.model.attr('container/style/cursor', 'default'); + this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-paper)'); + this.model.attr('container/style/borderColor', 'var(--mui-palette-border-main)'); + } }, onContextMenu: function(evt: MouseEvent) { @@ -47,6 +51,43 @@ export const EntityElementView = dia.ElementView.extend({ window.dispatchEvent(contextMenuEvent); }, + onPointerDown: function(evt: PointerEvent) { + this.model.attr('container/style/cursor', 'grabbing'); + + const contextMenuEvent = new CustomEvent('selectObject', { + detail: { + type: 'entity', + objectId: String(this.model.id), + data: this.model.get('entityData') + } + }); + window.dispatchEvent(contextMenuEvent); + }, + + onPointerUp: function(evt: PointerEvent) { + this.model.attr('container/style/cursor', 'move'); + }, + + onSelect: function() { + // Apply the same styling as hover but for selection + this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-default)'); + this.model.attr('container/style/borderColor', 'var(--mui-palette-primary-main)'); + this.model.attr('container/style/cursor', 'move'); + + // Mark as selected for state tracking + this.isSelected = true; + }, + + onDeselect: function() { + // Remove selection styling back to normal state + this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-paper)'); + this.model.attr('container/style/borderColor', 'var(--mui-palette-border-main)'); + this.model.attr('container/style/cursor', 'default'); + + // Mark as not selected + this.isSelected = false; + }, + updateTitle: function() { const label = this.model.get('label') || 'Entity'; this.model.attr('title/html', label); @@ -76,7 +117,7 @@ export const EntityElement = dia.Element.define('diagram.EntityElement', { width: '100%', height: '100%', backgroundColor: 'var(--mui-palette-background-paper)', - border: '2px solid var(--mui-palette-primary-main)', + border: '2px solid var(--mui-palette-border-main)', borderRadius: '8px', display: 'flex', alignItems: 'center', @@ -115,12 +156,14 @@ export function createEntity(options: { position?: { x: number; y: number }; title?: string; size?: { width: number; height: number }; + entityData?: EntityType; } = {}) { const label = options.title || 'New Entity'; const entity = new EntityElement({ position: options.position || { x: 0, y: 0 }, size: options.size || { width: 120, height: 80 }, - label + label, + entityData: options.entityData }); return entity; diff --git a/Website/components/diagramview/diagram-elements/Selection.ts b/Website/components/diagramview/diagram-elements/Selection.ts index f9e53bb..dd6d841 100644 --- a/Website/components/diagramview/diagram-elements/Selection.ts +++ b/Website/components/diagramview/diagram-elements/Selection.ts @@ -4,6 +4,7 @@ export const SelectionElement = dia.Element.define('selection.SelectionElement', size: { width: 100, height: 100 }, attrs: { body: { + cursor: 'move', refWidth: '100%', refHeight: '100%', stroke: '#2F80ED', @@ -130,6 +131,10 @@ export default class EntitySelection { .map((el) => el.getBBox({ deep: true, useModelGeometry: true })) .reduce((acc, r) => acc ? acc.union(r) : r, null as g.Rect | null) as g.Rect; + if (groupBBox === null) { + return; + } + // Create or update the SelectionElement sized/positioned to the bounding box if (!this.selectionElement) { this.selectionElement = new SelectionElement({ @@ -151,7 +156,10 @@ export default class EntitySelection { const prev = this.selectionElement.getEmbeddedCells(); prev.forEach((c) => this.selectionElement!.unembed(c)); - inside.forEach((el) => this.selectionElement!.embed(el)); + inside.forEach((el) => { + this.selectionElement!.embed(el); + (el.findView(this.paper) as any).onSelect(); + }); // Optional visual affordance when active this.selectionElement.attr(['body', 'stroke'], '#2F80ED'); @@ -167,6 +175,8 @@ export default class EntitySelection { for (const k of kids) { this.selectionElement.unembed(k); this.paper.findViewByModel(k)?.setInteractivity(true); + // Call onDeselect to remove selection styling + (k.findView(this.paper) as any).onDeselect(); } // Now it's safe to remove just the container @@ -189,10 +199,15 @@ export default class EntitySelection { // Public API (optional): clear selection public clear() { + // Call onDeselect on all currently selected elements + this.elements.toArray().forEach(el => { + (el.findView(this.paper) as any).onDeselect(); + }); + this.elements.reset([]); if (this.selectionElement) { - this.selectionElement.remove(); - this.selectionElement = null; + this.selectionElement.remove(); + this.selectionElement = null; } } } \ No newline at end of file diff --git a/Website/components/diagramview/events/SelectObjectEvent.ts b/Website/components/diagramview/events/SelectObjectEvent.ts new file mode 100644 index 0000000..a365d79 --- /dev/null +++ b/Website/components/diagramview/events/SelectObjectEvent.ts @@ -0,0 +1,7 @@ +import { EntityType } from "@/lib/Types"; + +export type SelectObjectEvent = { + type: 'entity'; + objectId: string | null; + data?: EntityType; +} \ No newline at end of file diff --git a/Website/components/diagramview/panes/EntitySelectionPane.tsx b/Website/components/diagramview/panes/EntitySelectionPane.tsx index 7a6b2af..62155f0 100644 --- a/Website/components/diagramview/panes/EntitySelectionPane.tsx +++ b/Website/components/diagramview/panes/EntitySelectionPane.tsx @@ -7,10 +7,14 @@ import { Typography, IconButton, Divider, - Paper + Paper, + Chip, + Button } from '@mui/material'; import { - Close as CloseIcon + Close as CloseIcon, + CloseRounded, + ExtensionRounded } from '@mui/icons-material'; import { useDatamodelData } from '@/contexts/DatamodelDataContext'; import { useDiagramView } from '@/contexts/DiagramViewContext'; @@ -24,17 +28,30 @@ interface EntitySelectionPaneProps { export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) => { const { groups } = useDatamodelData(); - const { addEntity, zoom, translate } = useDiagramView(); + const { addEntity } = useDiagramView(); const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [selectedEntities, setSelectedEntities] = useState([]); - const handleEntitySelect = useCallback((entity: EntityType, groupName: string) => { - - // Use DisplayName for the entity label - addEntity(undefined, entity.DisplayName); - - // Close the pane after selection + const handleEntitySelect = useCallback((entity: EntityType) => { + setSelectedEntities(prev => { + if (prev.find(e => e.SchemaName === entity.SchemaName)) { + return prev; + } + return [...prev, entity]; + }); + }, []); + + const handleEntityDeselect = useCallback((entity: EntityType) => { + setSelectedEntities(prev => prev.filter(e => e.SchemaName !== entity.SchemaName)); + }, []); + + const handleSubmit = () => { + selectedEntities.forEach(entity => { + addEntity(undefined, entity.DisplayName, entity); + }); + setSelectedEntities([]); onClose(); - }, [translate, zoom, addEntity, onClose]); + } const handleGroupToggle = useCallback((groupName: string) => { setExpandedGroups(prev => { @@ -71,6 +88,29 @@ export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) + + + + Selected: + + {selectedEntities.map((entity) => ( + : } deleteIcon={} + label={entity.DisplayName} + onDelete={() => handleEntityDeselect(entity)} /> + ))} + + + {groups.length === 0 ? ( @@ -90,6 +130,12 @@ export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) ))} )} + + + + ); diff --git a/Website/components/diagramview/panes/RelatedEntitiesPane.tsx b/Website/components/diagramview/panes/RelatedEntitiesPane.tsx new file mode 100644 index 0000000..68c0823 --- /dev/null +++ b/Website/components/diagramview/panes/RelatedEntitiesPane.tsx @@ -0,0 +1,151 @@ +'use client'; + +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { + Drawer, + Box, + Typography, + IconButton, + Divider, + Paper +} from '@mui/material'; +import { + Close as CloseIcon +} from '@mui/icons-material'; +import { useDatamodelData } from '@/contexts/DatamodelDataContext'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; +import { GroupType, EntityType } from '@/lib/Types'; +import { EntityGroupAccordion } from '@/components/shared/elements/EntityGroupAccordion'; + +interface RelatedEntitiesPaneProps { + open: boolean; + onClose: () => void; + entity: EntityType; +} + +export const RelatedEntitiesPane = ({ open, onClose, entity }: RelatedEntitiesPaneProps) => { + const { groups } = useDatamodelData(); + const { addEntity } = useDiagramView(); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + // Find related entities using memoization for performance + const relatedEntitiesGroups = useMemo(() => { + if (!entity || !groups) return []; + + // Get related entity schema names from different sources + const relatedFromRelationships = new Set(); + const relatedFromLookups = new Set(); + + // From relationships (TableSchema contains the related entity) + entity.Relationships.forEach(rel => { + relatedFromRelationships.add(rel.TableSchema); + }); + + // From lookup attributes (Targets contains related entities) + entity.Attributes.forEach(attr => { + if (attr.AttributeType === 'LookupAttribute') { + attr.Targets.forEach(target => { + if (target.IsInSolution) { + relatedFromLookups.add(target.Name); + } + }); + } + }); + + // Combine all related entities + const allRelatedSchemaNames = new Set([...relatedFromRelationships, ...relatedFromLookups]); + + // Remove the current entity itself + allRelatedSchemaNames.delete(entity.SchemaName); + + if (allRelatedSchemaNames.size === 0) return []; + + // Group related entities by their groups + const relatedGroups: GroupType[] = []; + + groups.forEach(group => { + const relatedEntitiesInGroup = group.Entities.filter(e => + allRelatedSchemaNames.has(e.SchemaName) + ); + + if (relatedEntitiesInGroup.length > 0) { + relatedGroups.push({ + Name: group.Name, + Entities: relatedEntitiesInGroup + }); + } + }); + + return relatedGroups; + }, [entity, groups]); + + const handleEntityClick = useCallback((clickedEntity: EntityType, groupName: string) => { + addEntity( + undefined, + clickedEntity.DisplayName, + clickedEntity + ); + }, [addEntity]); + + const handleGroupToggle = useCallback((groupName: string) => { + setExpandedGroups(prev => { + const newExpanded = new Set(prev); + if (newExpanded.has(groupName)) { + newExpanded.delete(groupName); + } else { + newExpanded.add(groupName); + } + return newExpanded; + }); + }, []); + + return ( + + + + + Related Entities + + + + + + + + Entities related to {entity.DisplayName} through relationships or lookups + + + + + {relatedEntitiesGroups.length === 0 ? ( + + No related entities found for this entity. + + ) : ( + + {relatedEntitiesGroups.map((group: GroupType) => ( + + ))} + + )} + + + ); +}; \ No newline at end of file diff --git a/Website/components/diagramview/smaller-components/EntityProperties.tsx b/Website/components/diagramview/smaller-components/EntityProperties.tsx new file mode 100644 index 0000000..3e75775 --- /dev/null +++ b/Website/components/diagramview/smaller-components/EntityProperties.tsx @@ -0,0 +1,59 @@ +import { EntityType } from '@/lib/Types'; +import { ExtensionRounded, AccountTreeRounded } from '@mui/icons-material'; +import { Box, Divider, Typography, Button } from '@mui/material'; +import React, { useState } from 'react'; +import { RelatedEntitiesPane } from '@/components/diagramview/panes/RelatedEntitiesPane'; +import { PathConnectionIcon } from '@/lib/icons'; + +interface IEntityPropertiesProps { + entity: EntityType | undefined; +} + +export default function EntityProperties({ entity }: IEntityPropertiesProps) { + const [relatedEntitiesPaneOpen, setRelatedEntitiesPaneOpen] = useState(false); + + if (!entity) { + return ( + Error: Entity not found. + ) + } + + const hasRelatedEntities = entity.Relationships.length > 0 || + entity.Attributes.some(attr => attr.AttributeType === 'LookupAttribute' && attr.Targets.length > 0); + + return ( + + {entity.IconBase64 ? +
: } + {entity?.DisplayName ?? 'Unknown Entity'} + + + {/* Related Entities Button */} + {hasRelatedEntities && ( + + )} + + setRelatedEntitiesPaneOpen(false)} + entity={entity} + /> + + ) +} diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 673e0c2..1a1c805 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -1,13 +1,14 @@ -import { dia, shapes, mvc } from '@joint/core'; +import { dia, shapes } from '@joint/core'; import React, { createContext, useContext, ReactNode, useReducer, useEffect, useRef } from 'react'; import { createEntity, EntityElement, EntityElementView } from '@/components/diagramview/diagram-elements/EntityElement'; import EntitySelection, { SelectionElement } from '@/components/diagramview/diagram-elements/Selection'; +import { EntityType } from '@/lib/Types'; interface DiagramActions { setZoom: (zoom: number) => void; setIsPanning: (isPanning: boolean) => void; setTranslate: (translate: { x: number; y: number }) => void; - addEntity: (position?: { x: number; y: number }, label?: string) => void; + addEntity: (position?: { x: number; y: number }, label?: string, entityData?: EntityType) => void; getGraph: () => dia.Graph | null; getPaper: () => dia.Paper | null; applyZoomAndPan: (zoom: number, translate: { x: number; y: number }) => void; @@ -128,6 +129,7 @@ const DiagramViewDispatcher = createContext>(( export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { const [diagramViewState, dispatch] = useReducer(diagramViewReducer, initialState); + const selectionRef = useRef(null); const setZoom = (zoom: number) => { dispatch({ type: 'SET_ZOOM', payload: zoom }); @@ -199,7 +201,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { paperRef.current = paper; diagramViewState.canvas.current.appendChild(paper.el); - const selection = new EntitySelection(paper); + selectionRef.current = new EntitySelection(paper); // Update all entity views with selection callbacks when entities are added // Variables for panning, zooming and selection @@ -354,7 +356,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }, []); // Context functions - const addEntity = (position?: { x: number; y: number }, label?: string) => { + const addEntity = (position?: { x: number; y: number }, label?: string, entityData?: EntityType) => { if (graphRef.current && paperRef.current) { let entityX: number; let entityY: number; @@ -388,7 +390,8 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { const entity = createEntity({ position: { x: entityX - 60, y: entityY - 40 }, // Center the entity (120x80 default size) title: entityLabel, - size: { width: 120, height: 80 } + size: { width: 120, height: 80 }, + entityData }); graphRef.current.addCell(entity); diff --git a/Website/lib/icons.tsx b/Website/lib/icons.tsx index a1df6ec..6f825d5 100644 --- a/Website/lib/icons.tsx +++ b/Website/lib/icons.tsx @@ -86,4 +86,15 @@ export const AzureDevOpsIcon = -; \ No newline at end of file +; + +export const CloseIcon = + + +; + +export const PathConnectionIcon = + + + + \ No newline at end of file From 88322eccb3e87b55acd3ed57f76614018581c8d2 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 22 Oct 2025 19:33:53 +0200 Subject: [PATCH 32/51] fix: disable entities already in the diagram when inserting from panes --- .../diagramview/panes/EntitySelectionPane.tsx | 5 +- .../diagramview/panes/RelatedEntitiesPane.tsx | 7 +- .../shared/elements/EntityGroupAccordion.tsx | 19 ++- Website/contexts/DiagramViewContext.tsx | 121 +++++++++++------- 4 files changed, 93 insertions(+), 59 deletions(-) diff --git a/Website/components/diagramview/panes/EntitySelectionPane.tsx b/Website/components/diagramview/panes/EntitySelectionPane.tsx index 62155f0..1b5354b 100644 --- a/Website/components/diagramview/panes/EntitySelectionPane.tsx +++ b/Website/components/diagramview/panes/EntitySelectionPane.tsx @@ -28,7 +28,7 @@ interface EntitySelectionPaneProps { export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) => { const { groups } = useDatamodelData(); - const { addEntity } = useDiagramView(); + const { addEntity, isEntityInDiagram } = useDiagramView(); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [selectedEntities, setSelectedEntities] = useState([]); @@ -47,7 +47,7 @@ export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) const handleSubmit = () => { selectedEntities.forEach(entity => { - addEntity(undefined, entity.DisplayName, entity); + addEntity(entity, undefined, entity.DisplayName); }); setSelectedEntities([]); onClose(); @@ -126,6 +126,7 @@ export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) onToggle={handleGroupToggle} onEntityClick={handleEntitySelect} showGroupClickIcon={false} + isDisabled={isEntityInDiagram} /> ))} diff --git a/Website/components/diagramview/panes/RelatedEntitiesPane.tsx b/Website/components/diagramview/panes/RelatedEntitiesPane.tsx index 68c0823..b7c58d0 100644 --- a/Website/components/diagramview/panes/RelatedEntitiesPane.tsx +++ b/Website/components/diagramview/panes/RelatedEntitiesPane.tsx @@ -25,7 +25,7 @@ interface RelatedEntitiesPaneProps { export const RelatedEntitiesPane = ({ open, onClose, entity }: RelatedEntitiesPaneProps) => { const { groups } = useDatamodelData(); - const { addEntity } = useDiagramView(); + const { addEntity, isEntityInDiagram } = useDiagramView(); const [expandedGroups, setExpandedGroups] = useState>(new Set()); // Find related entities using memoization for performance @@ -81,9 +81,9 @@ export const RelatedEntitiesPane = ({ open, onClose, entity }: RelatedEntitiesPa const handleEntityClick = useCallback((clickedEntity: EntityType, groupName: string) => { addEntity( + clickedEntity, undefined, - clickedEntity.DisplayName, - clickedEntity + clickedEntity.DisplayName ); }, [addEntity]); @@ -141,6 +141,7 @@ export const RelatedEntitiesPane = ({ open, onClose, entity }: RelatedEntitiesPa onToggle={handleGroupToggle} onEntityClick={handleEntityClick} showGroupClickIcon={false} + isDisabled={isEntityInDiagram} /> ))} diff --git a/Website/components/shared/elements/EntityGroupAccordion.tsx b/Website/components/shared/elements/EntityGroupAccordion.tsx index 171695d..5c4c01c 100644 --- a/Website/components/shared/elements/EntityGroupAccordion.tsx +++ b/Website/components/shared/elements/EntityGroupAccordion.tsx @@ -32,6 +32,7 @@ interface EntityGroupAccordionProps { highlightText?: (text: string, searchTerm: string) => React.ReactNode; isEntityMatch?: (entity: EntityType) => boolean; showGroupClickIcon?: boolean; + isDisabled?: (entity: EntityType) => boolean; } export const EntityGroupAccordion = ({ @@ -46,7 +47,8 @@ export const EntityGroupAccordion = ({ searchTerm = '', highlightText, isEntityMatch, - showGroupClickIcon = false + showGroupClickIcon = false, + isDisabled }: EntityGroupAccordionProps) => { const theme = useTheme(); const isCurrentGroup = currentGroup?.toLowerCase() === group.Name.toLowerCase(); @@ -136,6 +138,7 @@ export const EntityGroupAccordion = ({ const isCurrentSection = currentSection?.toLowerCase() === entity.SchemaName.toLowerCase(); const isMatch = isEntityMatch ? isEntityMatch(entity) : false; const isLoading = loadingSection === entity.SchemaName; + const isCurrentDisabled = isDisabled && isDisabled(entity); // If searching and this entity doesn't match, don't render it if (searchTerm.trim() && !isMatch) { @@ -146,15 +149,16 @@ export const EntityGroupAccordion = ({ )} + + + setRelatedEntitiesPaneOpen(false)} diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 69a333c..2dc94b9 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -9,6 +9,7 @@ interface DiagramActions { setIsPanning: (isPanning: boolean) => void; setTranslate: (translate: { x: number; y: number }) => void; addEntity: (entityData: EntityType, position?: { x: number; y: number }, label?: string) => void; + removeEntity: (entitySchemaName: string) => void; getGraph: () => dia.Graph | null; getPaper: () => dia.Paper | null; applyZoomAndPan: (zoom: number, translate: { x: number; y: number }) => void; @@ -51,6 +52,7 @@ const initialState: DiagramState = { setIsPanning: () => { throw new Error("setIsPanning not initialized yet!"); }, setTranslate: () => { throw new Error("setTranslate not initialized yet!"); }, addEntity: () => { throw new Error("addEntity not initialized yet!"); }, + removeEntity: () => { throw new Error("removeEntity not initialized yet!"); }, getGraph: () => { throw new Error("getGraph not initialized yet!"); }, getPaper: () => { throw new Error("getPaper not initialized yet!"); }, applyZoomAndPan: () => { throw new Error("applyZoomAndPan not initialized yet!"); }, @@ -419,6 +421,32 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { return null; }; + const removeEntity = (entitySchemaName: string) => { + if (graphRef.current) { + const entityElement = graphRef.current.getElements().find(el => { + const elementType = el.get('type'); + const entityData = el.get('entityData'); + + const isEntityElement = elementType === 'diagram.EntityElement'; + const hasMatchingSchema = entityData?.SchemaName === entitySchemaName; + + return isEntityElement && hasMatchingSchema; + }); + + if (entityElement) { + // Remove all links connected to this entity + const connectedLinks = graphRef.current.getConnectedLinks(entityElement); + connectedLinks.forEach(link => link.remove()); + // Remove the entity element from the graph + entityElement.remove(); + // Dispatch action to update the entities map in state + dispatch({ type: 'REMOVE_ENTITY_FROM_DIAGRAM', payload: entitySchemaName }); + } else { + console.warn('Entity not found in diagram:', entitySchemaName); + } + } + }; + const getGraph = () => { return graphRef.current; }; @@ -509,7 +537,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }; return ( - + {children} From 4ab492afe4889a681a51fa3512c6ee3e5aec2071 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 22 Oct 2025 21:21:01 +0200 Subject: [PATCH 34/51] feat: Selection properties and first simple gridlayout for entity selection --- .../diagramview/DiagramContainer.tsx | 13 +- .../diagramview/PropertiesPanel.tsx | 33 ++-- .../diagram-elements/EntityElement.ts | 26 +-- .../diagramview/diagram-elements/Selection.ts | 41 ++++ .../diagramview/events/SelectObjectEvent.ts | 4 +- .../diagramview/layout/GridLayoutManager.ts | 172 ----------------- .../diagramview/layout/SmartLayout.ts | 182 ++++++++++++++++++ .../smaller-components/EntityProperties.tsx | 9 +- .../SelectionProperties.tsx | 43 +++++ Website/contexts/DiagramViewContext.tsx | 83 +++++++- Website/lib/diagram/DiagramEventBridge.ts | 100 ++++++++++ Website/lib/icons.tsx | 8 +- 12 files changed, 498 insertions(+), 216 deletions(-) delete mode 100644 Website/components/diagramview/layout/GridLayoutManager.ts create mode 100644 Website/components/diagramview/layout/SmartLayout.ts create mode 100644 Website/components/diagramview/smaller-components/SelectionProperties.tsx create mode 100644 Website/lib/diagram/DiagramEventBridge.ts diff --git a/Website/components/diagramview/DiagramContainer.tsx b/Website/components/diagramview/DiagramContainer.tsx index 82eb1cc..12e16b5 100644 --- a/Website/components/diagramview/DiagramContainer.tsx +++ b/Website/components/diagramview/DiagramContainer.tsx @@ -5,6 +5,7 @@ import { Box } from "@mui/material"; import { useDiagramView } from "@/contexts/DiagramViewContext"; import { EntityContextMenu } from "./smaller-components/EntityContextMenu"; import PropertiesPanel from "./PropertiesPanel"; +import { diagramEvents } from "@/lib/diagram/DiagramEventBridge"; interface IDiagramContainerProps { @@ -21,21 +22,17 @@ export default function DiagramContainer({ }: IDiagramContainerProps) { position: null }); + // Use the event bridge for diagram events useEffect(() => { - const handleEntityContextMenu = (evt: CustomEvent) => { - const { entityId, x, y } = evt.detail; + const cleanup = diagramEvents.onContextMenuEvent((entityId, x, y) => { setContextMenu({ open: true, position: { top: y, left: x }, entityId: entityId }); - }; - - window.addEventListener('entityContextMenu', handleEntityContextMenu as EventListener); + }); - return () => { - window.removeEventListener('entityContextMenu', handleEntityContextMenu as EventListener); - }; + return cleanup; }, []); const handleCloseContextMenu = () => { diff --git a/Website/components/diagramview/PropertiesPanel.tsx b/Website/components/diagramview/PropertiesPanel.tsx index 42c6af4..21f5cf8 100644 --- a/Website/components/diagramview/PropertiesPanel.tsx +++ b/Website/components/diagramview/PropertiesPanel.tsx @@ -1,8 +1,10 @@ -import React, { useEffect, useState } from 'react' -import { SelectObjectEvent } from './events/SelectObjectEvent'; +import React, { useState, useEffect, useRef } from 'react' import { Box, Divider, IconButton } from '@mui/material'; import { CloseIcon } from '@/lib/icons'; import EntityProperties from './smaller-components/EntityProperties'; +import { SelectionProperties } from './smaller-components/SelectionProperties'; +import { diagramEvents } from '@/lib/diagram/DiagramEventBridge'; +import { SelectObjectEvent } from './events/SelectObjectEvent'; interface IPropertiesPanelProps { @@ -10,29 +12,36 @@ interface IPropertiesPanelProps { export default function PropertiesPanel({ }: IPropertiesPanelProps) { const [object, setObject] = useState(null); + const objectRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const close = () => { setIsOpen(false); setObject(null); - } + }; useEffect(() => { - const handleObjectSelection = (evt: CustomEvent) => { - console.log("Object selected:", evt.detail); - setObject(evt.detail); + objectRef.current = object; + }, [object]); + + useEffect(() => { + const cleanup = diagramEvents.onSelectionEvent((event) => { + if (event.type === 'entity' && objectRef.current?.type === 'selection') { + return; + } + setObject(event); setIsOpen(true); - }; - window.addEventListener('selectObject', handleObjectSelection as EventListener); - return () => { - window.removeEventListener('selectObject', handleObjectSelection as EventListener); - }; + }); + + return cleanup; }, []); const getProperties = () => { switch (object?.type) { case 'entity': - return ; + return ; + case 'selection': + return ; } } diff --git a/Website/components/diagramview/diagram-elements/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts index 225059a..99bbe64 100644 --- a/Website/components/diagramview/diagram-elements/EntityElement.ts +++ b/Website/components/diagramview/diagram-elements/EntityElement.ts @@ -1,6 +1,7 @@ import { dia, util } from '@joint/core'; import { SelectObjectEvent } from '../events/SelectObjectEvent'; import { EntityType } from '@/lib/Types'; +import { diagramEvents } from '@/lib/diagram/DiagramEventBridge'; export const EntityElementView = dia.ElementView.extend({ @@ -41,27 +42,20 @@ export const EntityElementView = dia.ElementView.extend({ evt.stopPropagation(); // Dispatch a custom event for context menu - const contextMenuEvent = new CustomEvent('entityContextMenu', { - detail: { - entityId: String(this.model.id), - x: evt.clientX, - y: evt.clientY - } - }); - window.dispatchEvent(contextMenuEvent); + diagramEvents.dispatchEntityContextMenu( + String(this.model.id), + evt.clientX, + evt.clientY + ); }, onPointerDown: function(evt: PointerEvent) { this.model.attr('container/style/cursor', 'grabbing'); - const contextMenuEvent = new CustomEvent('selectObject', { - detail: { - type: 'entity', - objectId: String(this.model.id), - data: this.model.get('entityData') - } - }); - window.dispatchEvent(contextMenuEvent); + diagramEvents.dispatchEntitySelect( + String(this.model.id), + this.model.get('entityData') + ); }, onPointerUp: function(evt: PointerEvent) { diff --git a/Website/components/diagramview/diagram-elements/Selection.ts b/Website/components/diagramview/diagram-elements/Selection.ts index dd6d841..7b13a83 100644 --- a/Website/components/diagramview/diagram-elements/Selection.ts +++ b/Website/components/diagramview/diagram-elements/Selection.ts @@ -1,4 +1,6 @@ import { dia, g, mvc, V, VElement } from "@joint/core"; +import { SelectObjectEvent } from "../events/SelectObjectEvent"; +import { diagramEvents } from "@/lib/diagram/DiagramEventBridge"; export const SelectionElement = dia.Element.define('selection.SelectionElement', { size: { width: 100, height: 100 }, @@ -99,6 +101,7 @@ export default class EntitySelection { this.cleanupOverlay(); this.teardownSelectionElement(); + diagramEvents.dispatchClear(); // Ignore tiny clicks (treat as no selection) if (selRect.width < 3 && selRect.height < 3) return; @@ -163,6 +166,15 @@ export default class EntitySelection { // Optional visual affordance when active this.selectionElement.attr(['body', 'stroke'], '#2F80ED'); + + // Dispatch selection event for properties panel + if (inside.length > 1) { + const selectedEntities = inside + .map(el => el.get('entityData')) + .filter(data => data != null); + + diagramEvents.dispatchSelectionChange(selectedEntities); + } }; // --- Helpers --------------------------------------------------------------- @@ -210,4 +222,33 @@ export default class EntitySelection { this.selectionElement = null; } } + + + // Public API: recalculate selection bounding box after entity positions change + public recalculateBoundingBox() { + const selected = this.elements.toArray(); + + if (selected.length <= 1) { + // If we have 1 or fewer elements, clear the selection element + this.teardownSelectionElement(); + return; + } + + // Recalculate the bounding box for all selected elements + const groupBBox = selected + .map((el) => el.getBBox({ deep: true, useModelGeometry: true })) + .reduce((acc, r) => acc ? acc.union(r) : r, null as g.Rect | null) as g.Rect; + + if (groupBBox === null) { + return; + } + + // Update the selection element position and size + if (this.selectionElement) { + this.selectionElement.position(groupBBox.x, groupBBox.y); + this.selectionElement.resize(groupBBox.width, groupBBox.height); + // Ensure it's behind again (in case z-order changed) + this.selectionElement.toBack(); + } + } } \ No newline at end of file diff --git a/Website/components/diagramview/events/SelectObjectEvent.ts b/Website/components/diagramview/events/SelectObjectEvent.ts index a365d79..cccfe8f 100644 --- a/Website/components/diagramview/events/SelectObjectEvent.ts +++ b/Website/components/diagramview/events/SelectObjectEvent.ts @@ -1,7 +1,7 @@ import { EntityType } from "@/lib/Types"; export type SelectObjectEvent = { - type: 'entity'; + type: 'none' | 'entity' | 'selection'; objectId: string | null; - data?: EntityType; + data?: EntityType[]; } \ No newline at end of file diff --git a/Website/components/diagramview/layout/GridLayoutManager.ts b/Website/components/diagramview/layout/GridLayoutManager.ts deleted file mode 100644 index 88a4eb4..0000000 --- a/Website/components/diagramview/layout/GridLayoutManager.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { EntityType } from '@/lib/Types'; - -export interface GridLayoutOptions { - containerWidth: number; - containerHeight: number; - entityWidth: number; - entityHeight: number; - padding: number; - margin: number; -} - -export interface GridPosition { - x: number; - y: number; -} - -export interface GridLayoutResult { - positions: GridPosition[]; - gridWidth: number; - gridHeight: number; - columns: number; - rows: number; -} - -/** - * Calculates the actual height of an entity based on its visible attributes and diagram type - */ -export const calculateEntityHeight = (): number => { return 80; }; - -/** - * Calculates optimal grid layout for entities based on screen aspect ratio - * Optionally avoids existing entity positions - */ -export const calculateGridLayout = ( - entities: EntityType[], - options: GridLayoutOptions, - existingPositions?: { x: number; y: number; width: number; height: number }[] -): GridLayoutResult => { - const { containerWidth, padding, margin } = options; - - if (entities.length === 0) { - return { - positions: [], - gridWidth: 0, - gridHeight: 0, - columns: 0, - rows: 0 - }; - } - - // If we have existing positions, we need to find the best starting position for new entities - let startColumn = 0; - let startRow = 0; - - if (existingPositions && existingPositions.length > 0) { - // Find the rightmost and bottommost positions - const maxX = Math.max(...existingPositions.map(pos => pos.x + pos.width)); - const maxY = Math.max(...existingPositions.map(pos => pos.y + pos.height)); - - // Get sample entity dimensions for spacing calculations - const sampleDimensions = estimateEntityDimensions(); - - // Start new entities to the right of existing ones, or on the next row - startColumn = Math.floor((maxX + padding - margin) / (sampleDimensions.width + padding)); - if (startColumn * (sampleDimensions.width + padding) + margin + sampleDimensions.width > containerWidth) { - // Move to next row if we can't fit horizontally - startColumn = 0; - startRow = Math.floor((maxY + padding - margin) / (sampleDimensions.height + padding)); - } - } - - // Determine how many columns can fit based on actual entity dimensions - const sampleEntityDimensions = estimateEntityDimensions(); - const actualEntityWidth = sampleEntityDimensions.width; - const maxColumns = Math.max(1, Math.floor((containerWidth - margin * 2 + padding) / (actualEntityWidth + padding))); - - // For collision avoidance, we'll place entities sequentially from the calculated starting position - const positions: GridPosition[] = []; - let currentColumn = startColumn; - let currentRow = startRow; - - for (let i = 0; i < entities.length; i++) { - const entityDimensions = estimateEntityDimensions(); - const height = entityDimensions.height; - const width = entityDimensions.width; - - // Find next available position that doesn't collide - let foundValidPosition = false; - let attempts = 0; - const maxAttempts = maxColumns * 10; // Prevent infinite loop - - while (!foundValidPosition && attempts < maxAttempts) { - // If we exceed the max columns, move to next row - if (currentColumn >= maxColumns) { - currentColumn = 0; - currentRow++; - } - - const x = margin + currentColumn * (width + padding); - const y = margin + currentRow * (height + padding); - - // Check if this position is occupied by existing entities - const isOccupied = existingPositions && existingPositions.length > 0 ? existingPositions.some(pos => { - const entityRight = x + width; - const entityBottom = y + height; - const existingRight = pos.x + pos.width; - const existingBottom = pos.y + pos.height; - - // Check for overlap with padding buffer - const buffer = padding / 4; - return !(entityRight + buffer < pos.x || - x > existingRight + buffer || - entityBottom + buffer < pos.y || - y > existingBottom + buffer); - }) : false; - - if (!isOccupied) { - positions.push({ x, y }); - foundValidPosition = true; - } - - // Move to next position - currentColumn++; - attempts++; - } - - if (!foundValidPosition) { - // Fallback: place at calculated position anyway (should not happen with enough attempts) - const x = margin + currentColumn * (width + padding); - const y = margin + currentRow * (height + padding); - positions.push({ x, y }); - currentColumn++; - } - } - - const sampleDimensions = estimateEntityDimensions(); - const gridWidth = Math.min(entities.length, maxColumns) * sampleDimensions.width + (Math.min(entities.length, maxColumns) - 1) * padding; - const gridHeight = (currentRow + 1) * (sampleDimensions.height + padding) - padding; - - return { - positions, - gridWidth, - gridHeight, - columns: Math.min(entities.length, maxColumns), - rows: currentRow + 1 - }; -}; - - -/** - * Estimates entity dimensions based on content and diagram type - */ -export const estimateEntityDimensions = (): { width: number; height: number } => { - return { - width: 200, - height: 80 - }; -}; - -/** - * Gets default layout options based on diagram type - */ -export const getDefaultLayoutOptions = (): GridLayoutOptions => { - return { - containerWidth: 1920, - containerHeight: 1080, - entityWidth: 200, // Smaller width for simple entities - entityHeight: 80, // Smaller height for simple entities - padding: 40, // Less padding for simple diagrams - margin: 40, // Less margin for simple diagrams - }; -}; \ No newline at end of file diff --git a/Website/components/diagramview/layout/SmartLayout.ts b/Website/components/diagramview/layout/SmartLayout.ts new file mode 100644 index 0000000..0674bcf --- /dev/null +++ b/Website/components/diagramview/layout/SmartLayout.ts @@ -0,0 +1,182 @@ +import { dia } from "@joint/core"; +import { EntityElement } from "../diagram-elements/EntityElement"; +import { EntityType } from "@/lib/Types"; + +export class SmartLayout { + private paper: dia.Paper; + private elements: InstanceType[]; + private gridSpacing: number = 180; // Space between entities + private centerOffset: number = 40; // Extra space around center entity + + constructor(paper: dia.Paper, elements: InstanceType[]) { + this.paper = paper; + this.elements = elements; + } + + /** + * Arranges entities with the most connected entity in the center + * and others in a grid layout around it + */ + public applyLayout(): void { + if (this.elements.length === 0) return; + + if (this.elements.length === 1) { + // Single entity - place in center of paper + this.placeSingleEntity(); + return; + } + + // Find the entity with the most relationships + const centerEntity = this.findMostConnectedEntity(); + + // Get remaining entities (excluding center) + const remainingEntities = this.elements.filter(el => el.id !== centerEntity.id); + + // Calculate paper center + const paperSize = this.paper.getComputedSize(); + const paperCenter = { + x: paperSize.width / 2, + y: paperSize.height / 2 + }; + + // Place center entity + this.positionEntity(centerEntity, paperCenter); + + // Arrange remaining entities in a grid around the center + this.arrangeEntitiesInGrid(remainingEntities, paperCenter); + } + + /** + * Places a single entity in the center of the paper + */ + private placeSingleEntity(): void { + const paperSize = this.paper.getComputedSize(); + const center = { + x: paperSize.width / 2, + y: paperSize.height / 2 + }; + this.positionEntity(this.elements[0], center); + } + + /** + * Finds the entity with the most relationships + */ + private findMostConnectedEntity(): InstanceType { + let maxConnections = -1; + let mostConnectedEntity = this.elements[0]; + + for (const element of this.elements) { + const entityData = element.get('entityData') as EntityType; + const connectionCount = entityData?.Relationships?.length || 0; + + if (connectionCount > maxConnections) { + maxConnections = connectionCount; + mostConnectedEntity = element; + } + } + + return mostConnectedEntity; + } + + /** + * Arranges entities in a grid pattern around a center point + */ + private arrangeEntitiesInGrid(entities: InstanceType[], centerPoint: { x: number; y: number }): void { + if (entities.length === 0) return; + + // Calculate grid dimensions - try to make it roughly square + const gridSize = Math.ceil(Math.sqrt(entities.length)); + + // Calculate starting position (top-left of the grid) + const totalGridWidth = (gridSize - 1) * this.gridSpacing; + const totalGridHeight = (gridSize - 1) * this.gridSpacing; + + const startX = centerPoint.x - totalGridWidth / 2; + const startY = centerPoint.y - totalGridHeight / 2 - this.centerOffset; + + let entityIndex = 0; + + for (let row = 0; row < gridSize && entityIndex < entities.length; row++) { + for (let col = 0; col < gridSize && entityIndex < entities.length; col++) { + // Skip the center position if it would conflict with center entity + const gridX = startX + col * this.gridSpacing; + const gridY = startY + row * this.gridSpacing; + + // Check if this position is too close to center + const distanceFromCenter = Math.sqrt( + Math.pow(gridX - centerPoint.x, 2) + Math.pow(gridY - centerPoint.y, 2) + ); + + if (distanceFromCenter < this.gridSpacing * 0.8) { + // Skip this position if too close to center + continue; + } + + const entity = entities[entityIndex]; + this.positionEntity(entity, { x: gridX, y: gridY }); + entityIndex++; + } + } + + // If we have entities left (because we skipped center positions), place them in a spiral + if (entityIndex < entities.length) { + this.arrangeSpiralLayout(entities.slice(entityIndex), centerPoint, gridSize); + } + } + + /** + * Arranges remaining entities in a spiral pattern for overflow + */ + private arrangeSpiralLayout(entities: InstanceType[], centerPoint: { x: number; y: number }, gridSize: number): void { + const spiralRadius = (gridSize + 1) * this.gridSpacing / 2; + const angleStep = (2 * Math.PI) / entities.length; + + entities.forEach((entity, index) => { + const angle = index * angleStep; + const x = centerPoint.x + spiralRadius * Math.cos(angle); + const y = centerPoint.y + spiralRadius * Math.sin(angle); + + this.positionEntity(entity, { x, y }); + }); + } + + /** + * Positions an entity at the specified coordinates (centered) + */ + private positionEntity(entity: InstanceType, position: { x: number; y: number }): void { + const entitySize = entity.get('size') || { width: 120, height: 80 }; + const centeredPosition = { + x: position.x - entitySize.width / 2, + y: position.y - entitySize.height / 2 + }; + + entity.set('position', centeredPosition); + } + + /** + * Sets custom grid spacing + */ + public setGridSpacing(spacing: number): void { + this.gridSpacing = spacing; + } + + /** + * Sets custom center offset + */ + public setCenterOffset(offset: number): void { + this.centerOffset = offset; + } + + /** + * Gets statistics about entity connections for debugging + */ + public getConnectionStats(): Array<{ entityName: string; connectionCount: number }> { + return this.elements.map(element => { + const entityData = element.get('entityData') as EntityType; + return { + entityName: entityData?.DisplayName || entityData?.SchemaName || 'Unknown', + connectionCount: entityData?.Relationships?.length || 0 + }; + }); + } +} \ No newline at end of file diff --git a/Website/components/diagramview/smaller-components/EntityProperties.tsx b/Website/components/diagramview/smaller-components/EntityProperties.tsx index 436617c..901c200 100644 --- a/Website/components/diagramview/smaller-components/EntityProperties.tsx +++ b/Website/components/diagramview/smaller-components/EntityProperties.tsx @@ -3,14 +3,15 @@ import { ExtensionRounded, AccountTreeRounded } from '@mui/icons-material'; import { Box, Divider, Typography, Button } from '@mui/material'; import React, { useState } from 'react'; import { RelatedEntitiesPane } from '@/components/diagramview/panes/RelatedEntitiesPane'; -import { PathConnectionIcon } from '@/lib/icons'; +import { BinIcon, PathConnectionIcon } from '@/lib/icons'; import { useDiagramView } from '@/contexts/DiagramViewContext'; interface IEntityPropertiesProps { entity: EntityType | undefined; + closePane: () => void; } -export default function EntityProperties({ entity }: IEntityPropertiesProps) { +export default function EntityProperties({ entity, closePane }: IEntityPropertiesProps) { const [relatedEntitiesPaneOpen, setRelatedEntitiesPaneOpen] = useState(false); const { removeEntity } = useDiagramView(); @@ -56,8 +57,8 @@ export default function EntityProperties({ entity }: IEntityPropertiesProps) { variant="outlined" color='error' className='self-end' - startIcon={{PathConnectionIcon}} - onClick={() => { removeEntity(entity.SchemaName); setRelatedEntitiesPaneOpen(false);}} + startIcon={{BinIcon}} + onClick={() => { removeEntity(entity.SchemaName); closePane(); }} fullWidth > Remove Entity diff --git a/Website/components/diagramview/smaller-components/SelectionProperties.tsx b/Website/components/diagramview/smaller-components/SelectionProperties.tsx new file mode 100644 index 0000000..46e72ca --- /dev/null +++ b/Website/components/diagramview/smaller-components/SelectionProperties.tsx @@ -0,0 +1,43 @@ +import { useDiagramView } from "@/contexts/DiagramViewContext"; +import { EntityType } from "@/lib/Types"; +import { Box, Button, Divider, Typography } from "@mui/material"; + +interface ISelectionPropertiesProps { + selectedEntities: EntityType[]; +} + +export const SelectionProperties = ({ selectedEntities }: ISelectionPropertiesProps) => { + const { applySmartLayout, getSelectedEntities } = useDiagramView(); + + // Get the current selected entities from the context + const currentlySelectedEntities = getSelectedEntities(); + + // Use the current selection if available, otherwise fall back to the prop + const entitiesToShow = currentlySelectedEntities.length > 0 ? currentlySelectedEntities : selectedEntities; + + const handleSmartLayout = () => { + if (entitiesToShow.length > 0) { + applySmartLayout(entitiesToShow); + } + }; + + return ( + + + {entitiesToShow.length > 0 + ? entitiesToShow.map(e => e.SchemaName).join(", ") + : "No Entities Selected" + } + + + + + ); +}; \ No newline at end of file diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 2dc94b9..5027d6a 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -2,6 +2,7 @@ import { dia, shapes } from '@joint/core'; import React, { createContext, useContext, ReactNode, useReducer, useEffect, useRef } from 'react'; import { createEntity, EntityElement, EntityElementView } from '@/components/diagramview/diagram-elements/EntityElement'; import EntitySelection, { SelectionElement } from '@/components/diagramview/diagram-elements/Selection'; +import { SmartLayout } from '@/components/diagramview/layout/SmartLayout'; import { EntityType } from '@/lib/Types'; interface DiagramActions { @@ -19,6 +20,8 @@ interface DiagramActions { selectEntity: (entityId: string, ctrlClick?: boolean) => void; clearSelection: () => void; isEntityInDiagram: (entity: EntityType) => boolean; + applySmartLayout: (entities: EntityType[]) => void; + getSelectedEntities: () => EntityType[]; } export interface DiagramState extends DiagramActions { @@ -62,6 +65,8 @@ const initialState: DiagramState = { selectEntity: () => { throw new Error("selectEntity not initialized yet!"); }, clearSelection: () => { throw new Error("clearSelection not initialized yet!"); }, isEntityInDiagram: () => { throw new Error("isEntityInDiagram not initialized yet!"); }, + applySmartLayout: () => { throw new Error("applySmartLayout not initialized yet!"); }, + getSelectedEntities: () => { throw new Error("getSelectedEntities not initialized yet!"); }, } type DiagramViewAction = @@ -536,8 +541,84 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { return diagramViewState.entitiesInDiagram.has(entity.SchemaName); }; + const applySmartLayout = (entities: EntityType[]) => { + if (graphRef.current && paperRef.current) { + // Get all entity elements from the graph + const entityElements = graphRef.current.getCells().filter( + cell => cell.get('type') === 'diagram.EntityElement' + ) as InstanceType[]; + + if (entityElements.length === 0) { + console.warn('No entities found to layout'); + return; + } + + const layoutEntities = entityElements.filter(el => { + const entityData = el.get('entityData') as EntityType; + return entities.some(e => e.SchemaName === entityData.SchemaName); + }); + + if (layoutEntities.length === 0) { + console.warn('No matching entities found in diagram for layout'); + return; + } + + // Create and apply the smart layout + const smartLayout = new SmartLayout(paperRef.current, layoutEntities); + smartLayout.applyLayout(); + + console.log('Smart layout applied to', layoutEntities.length, 'entities'); + + // Optional: Log connection statistics for debugging + const stats = smartLayout.getConnectionStats(); + console.log('Entity connection stats:', stats); + + // Recalculate selection bounding box after layout change + if (selectionRef.current) { + selectionRef.current.recalculateBoundingBox(); + } + } else { + console.error('Graph or Paper not initialized'); + } + }; + + const getSelectedEntities = (): EntityType[] => { + if (!graphRef.current) { + return []; + } + + // Get currently selected entity IDs from state + const selectedEntityIds = diagramViewState.selectedEntities; + + if (selectedEntityIds.length === 0) { + // If no individual entity selection, check for area selection + if (selectionRef.current) { + const selectedElements = selectionRef.current.getSelected(); + return selectedElements + .filter(el => el.get('type') === 'diagram.EntityElement') + .map(el => el.get('entityData') as EntityType) + .filter(data => data != null); + } + return []; + } + + // Get entities by their IDs + const entities: EntityType[] = []; + for (const entityId of selectedEntityIds) { + const element = graphRef.current.getCell(entityId); + if (element && element.get('type') === 'diagram.EntityElement') { + const entityData = element.get('entityData') as EntityType; + if (entityData) { + entities.push(entityData); + } + } + } + + return entities; + }; + return ( - + {children} diff --git a/Website/lib/diagram/DiagramEventBridge.ts b/Website/lib/diagram/DiagramEventBridge.ts new file mode 100644 index 0000000..4b6affb --- /dev/null +++ b/Website/lib/diagram/DiagramEventBridge.ts @@ -0,0 +1,100 @@ +import { SelectObjectEvent } from '@/components/diagramview/events/SelectObjectEvent'; + +/** + * Event bridge class that provides a simple interface for both React and Joint.js components + * to dispatch and listen to diagram events without complex hook or context overhead. + */ +export class DiagramEventBridge { + private static instance: DiagramEventBridge; + + private constructor() {} + + static getInstance(): DiagramEventBridge { + if (!DiagramEventBridge.instance) { + DiagramEventBridge.instance = new DiagramEventBridge(); + } + return DiagramEventBridge.instance; + } + + // Event dispatching methods - can be called from anywhere + dispatchEntitySelect(entityId: string, entityData: any) { + const event = new CustomEvent('selectObject', { + detail: { + type: 'entity', + objectId: entityId, + data: [entityData] + } + }); + window.dispatchEvent(event); + } + + dispatchSelectionChange(entities: any[]) { + const event = new CustomEvent('selectObject', { + detail: { + type: 'selection', + objectId: null, + data: entities + } + }); + window.dispatchEvent(event); + } + + dispatchClear() { + const event = new CustomEvent('selectObject', { + detail: { + type: 'none', + objectId: null, + data: [] + } + }); + window.dispatchEvent(event); + } + + dispatchEntityContextMenu(entityId: string, x: number, y: number) { + const event = new CustomEvent('entityContextMenu', { + detail: { entityId, x, y } + }); + window.dispatchEvent(event); + } + + // Event listening methods - can be used by React components + addEventListener(eventType: 'selectObject', handler: (event: CustomEvent) => void): void; + addEventListener(eventType: 'entityContextMenu', handler: (event: CustomEvent) => void): void; + addEventListener(eventType: string, handler: (event: CustomEvent) => void): void { + window.addEventListener(eventType, handler as EventListener); + } + + removeEventListener(eventType: 'selectObject', handler: (event: CustomEvent) => void): void; + removeEventListener(eventType: 'entityContextMenu', handler: (event: CustomEvent) => void): void; + removeEventListener(eventType: string, handler: (event: CustomEvent) => void): void { + window.removeEventListener(eventType, handler as EventListener); + } + + // Convenience method for React components that need to handle selection events + onSelectionEvent(callback: (event: SelectObjectEvent) => void): () => void { + const handler = (evt: CustomEvent) => { + callback(evt.detail); + }; + + this.addEventListener('selectObject', handler); + + // Return cleanup function + return () => this.removeEventListener('selectObject', handler); + } + + // Convenience method for React components that need to handle context menu events + onContextMenuEvent(callback: (entityId: string, x: number, y: number) => void): () => void { + const handler = (evt: CustomEvent) => { + const { entityId, x, y } = evt.detail; + callback(entityId, x, y); + }; + + this.addEventListener('entityContextMenu', handler); + + // Return cleanup function + return () => this.removeEventListener('entityContextMenu', handler); + } +} + +// Export singleton instance for easy access +export const diagramEvents = DiagramEventBridge.getInstance(); \ No newline at end of file diff --git a/Website/lib/icons.tsx b/Website/lib/icons.tsx index 6f825d5..8b791c7 100644 --- a/Website/lib/icons.tsx +++ b/Website/lib/icons.tsx @@ -97,4 +97,10 @@ export const PathConnectionIcon = - \ No newline at end of file + + +export const BinIcon = + + + +; \ No newline at end of file From 5b811a29e6988eaced8710233b73303d3a288e37 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Wed, 22 Oct 2025 21:35:53 +0200 Subject: [PATCH 35/51] chore: es lint error fixes --- Website/app/layout.tsx | 1 - Website/app/metadata/page.tsx | 1 - .../datamodelview/SidebarDatamodelView.tsx | 4 +-- .../diagramview/SidebarDiagramView.tsx | 3 +- .../diagram-elements/EntityElement.ts | 32 ++++++++++++------- .../diagramview/diagram-elements/Selection.ts | 8 ++--- .../diagramview/modals/LoadDiagramModal.tsx | 2 -- .../diagramview/panes/RelatedEntitiesPane.tsx | 4 +-- .../panes/VersionHistorySidepane.tsx | 2 -- .../smaller-components/EntityContextMenu.tsx | 3 +- .../smaller-components/EntityProperties.tsx | 2 +- .../smaller-components/HeaderMenuItem.tsx | 2 +- Website/lib/diagram/DiagramEventBridge.ts | 6 ++-- .../services/diagram-deserialization.ts | 4 +-- 14 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Website/app/layout.tsx b/Website/app/layout.tsx index 58a53c7..9613c76 100644 --- a/Website/app/layout.tsx +++ b/Website/app/layout.tsx @@ -4,7 +4,6 @@ import { SidebarProvider } from "@/contexts/SidebarContext"; import { SettingsProvider } from "@/contexts/SettingsContext"; import { AuthProvider } from "@/contexts/AuthContext"; import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter'; -import { DatamodelViewProvider } from "@/contexts/DatamodelViewContext"; import { SnackbarProvider } from "@/contexts/SnackbarContext"; import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; diff --git a/Website/app/metadata/page.tsx b/Website/app/metadata/page.tsx index 90cd881..bacc9af 100644 --- a/Website/app/metadata/page.tsx +++ b/Website/app/metadata/page.tsx @@ -1,5 +1,4 @@ import { DatamodelView } from "@/components/datamodelview/DatamodelView"; -import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext"; import Layout from "@/components/shared/Layout"; import { Suspense } from "react"; import { DatamodelViewProvider } from "@/contexts/DatamodelViewContext"; diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx index 1b8f8d8..18c53d0 100644 --- a/Website/components/datamodelview/SidebarDatamodelView.tsx +++ b/Website/components/datamodelview/SidebarDatamodelView.tsx @@ -1,7 +1,6 @@ import { EntityType, GroupType } from "@/lib/Types"; import { useSidebar } from '@/contexts/SidebarContext'; -import { cn } from "@/lib/utils"; -import { Box, InputAdornment, Paper, Typography, CircularProgress } from '@mui/material'; +import { Box, InputAdornment, Paper } from '@mui/material'; import { SearchRounded } from '@mui/icons-material'; import { useState, useEffect, useMemo, useCallback } from "react"; import { TextField } from "@mui/material"; @@ -14,7 +13,6 @@ interface ISidebarDatamodelViewProps { } - export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => { const { currentSection, currentGroup, scrollToSection, scrollToGroup, loadingSection } = useDatamodelView(); const { close: closeSidebar } = useSidebar(); diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index 8b84707..87a66e8 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -1,6 +1,5 @@ -import { Box, Tooltip, Typography, Grid, TextField, Divider, Button } from '@mui/material'; +import { Box, Tooltip, Typography, Grid, TextField, Divider } from '@mui/material'; import React, { useState } from 'react'; -import { AddSquareIcon } from '@/lib/icons'; import { EntitySelectionPane } from './panes/EntitySelectionPane'; import { useDiagramView } from '@/contexts/DiagramViewContext'; diff --git a/Website/components/diagramview/diagram-elements/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts index 99bbe64..dc7bf86 100644 --- a/Website/components/diagramview/diagram-elements/EntityElement.ts +++ b/Website/components/diagramview/diagram-elements/EntityElement.ts @@ -1,8 +1,23 @@ -import { dia, util } from '@joint/core'; -import { SelectObjectEvent } from '../events/SelectObjectEvent'; +import { dia, mvc, util } from '@joint/core'; import { EntityType } from '@/lib/Types'; import { diagramEvents } from '@/lib/diagram/DiagramEventBridge'; +export type EntityElement = dia.Element & { + get(key: 'entityData'): EntityType | undefined; + get(key: 'label'): string | undefined; +}; +export type EntityElementView = dia.ElementView & { + onSelect(): void; + onDeselect(): void; +}; + +interface IEntityOptions extends mvc.ViewBaseOptions { + position?: { x: number; y: number }; + title?: string; + size?: { width: number; height: number }; + entityData?: EntityType; +}; + export const EntityElementView = dia.ElementView.extend({ events: { @@ -13,7 +28,7 @@ export const EntityElementView = dia.ElementView.extend({ 'pointerup': 'onPointerUp', }, - initialize: function(options?: any) { + initialize: function(options?: IEntityOptions) { dia.ElementView.prototype.initialize.call(this, options); this.updateTitle(); this.isSelected = false; // Track selection state @@ -49,7 +64,7 @@ export const EntityElementView = dia.ElementView.extend({ ); }, - onPointerDown: function(evt: PointerEvent) { + onPointerDown: function() { this.model.attr('container/style/cursor', 'grabbing'); diagramEvents.dispatchEntitySelect( @@ -58,7 +73,7 @@ export const EntityElementView = dia.ElementView.extend({ ); }, - onPointerUp: function(evt: PointerEvent) { + onPointerUp: function() { this.model.attr('container/style/cursor', 'move'); }, @@ -146,12 +161,7 @@ export const EntityElement = dia.Element.define('diagram.EntityElement', { ` }); -export function createEntity(options: { - position?: { x: number; y: number }; - title?: string; - size?: { width: number; height: number }; - entityData?: EntityType; -} = {}) { +export function createEntity(options: IEntityOptions = {}) { const label = options.title || 'New Entity'; const entity = new EntityElement({ position: options.position || { x: 0, y: 0 }, diff --git a/Website/components/diagramview/diagram-elements/Selection.ts b/Website/components/diagramview/diagram-elements/Selection.ts index 7b13a83..f7905d9 100644 --- a/Website/components/diagramview/diagram-elements/Selection.ts +++ b/Website/components/diagramview/diagram-elements/Selection.ts @@ -1,6 +1,6 @@ import { dia, g, mvc, V, VElement } from "@joint/core"; -import { SelectObjectEvent } from "../events/SelectObjectEvent"; import { diagramEvents } from "@/lib/diagram/DiagramEventBridge"; +import { EntityElementView } from "./EntityElement"; export const SelectionElement = dia.Element.define('selection.SelectionElement', { size: { width: 100, height: 100 }, @@ -161,7 +161,7 @@ export default class EntitySelection { inside.forEach((el) => { this.selectionElement!.embed(el); - (el.findView(this.paper) as any).onSelect(); + (el.findView(this.paper) as EntityElementView).onSelect(); }); // Optional visual affordance when active @@ -188,7 +188,7 @@ export default class EntitySelection { this.selectionElement.unembed(k); this.paper.findViewByModel(k)?.setInteractivity(true); // Call onDeselect to remove selection styling - (k.findView(this.paper) as any).onDeselect(); + (k.findView(this.paper) as EntityElementView).onDeselect(); } // Now it's safe to remove just the container @@ -213,7 +213,7 @@ export default class EntitySelection { public clear() { // Call onDeselect on all currently selected elements this.elements.toArray().forEach(el => { - (el.findView(this.paper) as any).onDeselect(); + (el.findView(this.paper) as EntityElementView).onDeselect(); }); this.elements.reset([]); diff --git a/Website/components/diagramview/modals/LoadDiagramModal.tsx b/Website/components/diagramview/modals/LoadDiagramModal.tsx index 788d438..1c14313 100644 --- a/Website/components/diagramview/modals/LoadDiagramModal.tsx +++ b/Website/components/diagramview/modals/LoadDiagramModal.tsx @@ -17,7 +17,6 @@ import { } from '@mui/material'; import { CloudDownload as CloudIcon, - Upload as UploadIcon, Close as CloseIcon, PolylineRounded, ArrowBack as ArrowBackIcon @@ -25,7 +24,6 @@ import { import { DiagramFile } from '@/lib/diagram/services/diagram-deserialization'; import { ClickableCard } from '@/components/shared/elements/ClickableCard'; import { AzureDevOpsIcon, LoadIcon } from '@/lib/icons'; -import { useDiagramView } from '@/contexts/DiagramViewContext'; import { useRepositoryInfo } from '@/hooks/useRepositoryInfo'; interface LoadDiagramModalProps { diff --git a/Website/components/diagramview/panes/RelatedEntitiesPane.tsx b/Website/components/diagramview/panes/RelatedEntitiesPane.tsx index b7c58d0..72cb3c7 100644 --- a/Website/components/diagramview/panes/RelatedEntitiesPane.tsx +++ b/Website/components/diagramview/panes/RelatedEntitiesPane.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { Drawer, Box, @@ -79,7 +79,7 @@ export const RelatedEntitiesPane = ({ open, onClose, entity }: RelatedEntitiesPa return relatedGroups; }, [entity, groups]); - const handleEntityClick = useCallback((clickedEntity: EntityType, groupName: string) => { + const handleEntityClick = useCallback((clickedEntity: EntityType) => { addEntity( clickedEntity, undefined, diff --git a/Website/components/diagramview/panes/VersionHistorySidepane.tsx b/Website/components/diagramview/panes/VersionHistorySidepane.tsx index 0dde454..255393b 100644 --- a/Website/components/diagramview/panes/VersionHistorySidepane.tsx +++ b/Website/components/diagramview/panes/VersionHistorySidepane.tsx @@ -10,12 +10,10 @@ import { ListItem, ListItemText, Divider, - CircularProgress, Alert, Chip, Skeleton, Button, - Tooltip } from '@mui/material'; import { Close as CloseIcon } from '@mui/icons-material'; import { useDiagramView } from '@/contexts/DiagramViewContext'; diff --git a/Website/components/diagramview/smaller-components/EntityContextMenu.tsx b/Website/components/diagramview/smaller-components/EntityContextMenu.tsx index a695f46..ea34d99 100644 --- a/Website/components/diagramview/smaller-components/EntityContextMenu.tsx +++ b/Website/components/diagramview/smaller-components/EntityContextMenu.tsx @@ -13,8 +13,7 @@ interface EntityContextMenuProps { export const EntityContextMenu: React.FC = ({ anchorPosition, open, - onClose, - entityId + onClose }) => { return ( ('selectObject', { detail: { type: 'entity', @@ -28,7 +28,7 @@ export class DiagramEventBridge { window.dispatchEvent(event); } - dispatchSelectionChange(entities: any[]) { + dispatchSelectionChange(entities: EntityType[]) { const event = new CustomEvent('selectObject', { detail: { type: 'selection', diff --git a/Website/lib/diagram/services/diagram-deserialization.ts b/Website/lib/diagram/services/diagram-deserialization.ts index 62ac098..1728bba 100644 --- a/Website/lib/diagram/services/diagram-deserialization.ts +++ b/Website/lib/diagram/services/diagram-deserialization.ts @@ -1,4 +1,4 @@ -import { dia, shapes } from '@joint/core'; +import { dia } from '@joint/core'; import { SerializedDiagram } from '../models/serialized-diagram'; import { SerializedEntity } from '../models/serialized-entity'; import { EntityElement } from '@/components/diagramview/diagram-elements/EntityElement'; @@ -63,7 +63,7 @@ export class DiagramDeserializationService { setLoadedDiagram(filename, source, filePath); // Recreate entities - diagramData.entities.forEach((entityData: SerializedEntity, index: number) => { + diagramData.entities.forEach((entityData: SerializedEntity) => { const entity = new EntityElement({ id: entityData.id, From 3c8e3ae5a4398ab2eed4689311c10c370a68ec8e Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Fri, 24 Oct 2025 21:22:31 +0200 Subject: [PATCH 36/51] feat: initial libavoid router reimplementation with external worker thread. Also custom defined relationshiplink for future logic --- .../avoid-router/{ => shared}/avoidrouter.ts | 0 .../diagramview/avoid-router/shared/events.ts | 9 ++ .../avoid-router/shared/initialization.ts | 93 +++++++++++ .../avoid-router/worker-thread/worker.ts | 151 ++++++++++++++++++ .../diagram-elements/RelationshipLink.ts | 51 ++++++ Website/contexts/DiagramViewContext.tsx | 101 +++++++++++- 6 files changed, 402 insertions(+), 3 deletions(-) rename Website/components/diagramview/avoid-router/{ => shared}/avoidrouter.ts (100%) create mode 100644 Website/components/diagramview/avoid-router/shared/events.ts create mode 100644 Website/components/diagramview/avoid-router/shared/initialization.ts create mode 100644 Website/components/diagramview/avoid-router/worker-thread/worker.ts create mode 100644 Website/components/diagramview/diagram-elements/RelationshipLink.ts diff --git a/Website/components/diagramview/avoid-router/avoidrouter.ts b/Website/components/diagramview/avoid-router/shared/avoidrouter.ts similarity index 100% rename from Website/components/diagramview/avoid-router/avoidrouter.ts rename to Website/components/diagramview/avoid-router/shared/avoidrouter.ts diff --git a/Website/components/diagramview/avoid-router/shared/events.ts b/Website/components/diagramview/avoid-router/shared/events.ts new file mode 100644 index 0000000..e1b3a70 --- /dev/null +++ b/Website/components/diagramview/avoid-router/shared/events.ts @@ -0,0 +1,9 @@ +export enum RouterRequestEvent { + Reset = 'reset', + Change = 'change', + Remove = 'remove', + Add = 'add' +} +export enum RouterResponseEvent { + Routed = 'routed' +} \ No newline at end of file diff --git a/Website/components/diagramview/avoid-router/shared/initialization.ts b/Website/components/diagramview/avoid-router/shared/initialization.ts new file mode 100644 index 0000000..5a55aa2 --- /dev/null +++ b/Website/components/diagramview/avoid-router/shared/initialization.ts @@ -0,0 +1,93 @@ +import { dia } from "@joint/core"; +import { AvoidRouter } from "./avoidrouter"; +import { RouterResponseEvent, RouterRequestEvent } from "./events"; + +export async function initializeRouter(graph: dia.Graph, paper: dia.Paper) { + await AvoidRouter.load(); + const routerWorker = new Worker(new URL("./../worker-thread/worker.ts", import.meta.url)); + + routerWorker.onmessage = (event: { data: { command: RouterResponseEvent, cells: any[] } }) => { + const { command, ...data } = event.data; + switch (command) { + case RouterResponseEvent.Routed: + const { cells } = data; + cells.forEach((cell) => { + const model = graph.getCell(cell.id); + if (model && !model.isElement()) { + model.set({ + vertices: cell.vertices, + source: cell.source, + target: cell.target, + router: null + }, { + fromWorker: true + }); + } + }); + break; + default: + console.log('Unknown response command', command); + break; + } + }; + + // Send initial reset with current graph state + routerWorker.postMessage({ + command: RouterRequestEvent.Reset, + cells: graph.toJSON().cells + }); + + // Register graph event listeners (outside of other event handlers) + graph.on('change', (cell, opt) => { + if (opt.fromWorker) { + return; + } + + // Only send relevant changes to avoid spam + if (cell.isElement() && (cell.hasChanged('position') || cell.hasChanged('size'))) { + routerWorker.postMessage({ + command: RouterRequestEvent.Change, + cell: cell.toJSON() + }); + + const links = graph.getConnectedLinks(cell); + links.forEach((link) => { + if (!link.router()) { + link.router('rightAngle'); + } + }); + } else if (!cell.isElement() && (cell.hasChanged('source') || cell.hasChanged('target') || cell.hasChanged('vertices'))) { + // Only send link changes for source, target, or vertices + routerWorker.postMessage({ + command: RouterRequestEvent.Change, + cell: cell.toJSON() + }); + } + }); + + graph.on('remove', (cell) => { + routerWorker.postMessage({ + command: RouterRequestEvent.Remove, + id: cell.id + }); + }); + + graph.on('add', (cell) => { + console.log("Adding cell:", cell); + routerWorker.postMessage({ + command: RouterRequestEvent.Add, + cell: cell.toJSON() + }); + }); + + paper.on('link:snap:connect', (linkView) => { + linkView.model.router('rightAngle'); + }); + + paper.on('link:snap:disconnect', (linkView) => { + linkView.model.set({ + vertices: [], + router: null + }); + }); + } \ No newline at end of file diff --git a/Website/components/diagramview/avoid-router/worker-thread/worker.ts b/Website/components/diagramview/avoid-router/worker-thread/worker.ts new file mode 100644 index 0000000..b5977ed --- /dev/null +++ b/Website/components/diagramview/avoid-router/worker-thread/worker.ts @@ -0,0 +1,151 @@ +import { RelationshipLink } from "../../diagram-elements/RelationshipLink"; +import { AvoidRouter } from "../shared/avoidrouter"; +import { RouterRequestEvent } from "../shared/events"; +import { dia, shapes, util } from "@joint/core"; + +const routerLoaded = AvoidRouter.load(); + +// Create simplified element definitions for worker context +// These don't need the full DOM functionality since they're just for routing +const WorkerEntityElement = dia.Element.define('diagram.EntityElement', { + // Minimal definition just for the worker + size: { width: 120, height: 80 }, + attrs: { + // Simplified attributes without SVG parsing + body: { + width: 'calc(w)', + height: 'calc(h)', + } + } +}); + +const WorkerSelectionElement = dia.Element.define('selection.SelectionElement', { + size: { width: 100, height: 100 }, + attrs: { + body: { + width: 'calc(w)', + height: 'calc(h)', + } + } +}); + +onmessage = async(e) => { + await routerLoaded; + + console.log("Worker received message:", e.data); + + const { command, ...data } = e.data; // Remove array destructuring + switch (command) { + case RouterRequestEvent.Reset: { + const { cells } = data; + console.log("Resetting graph with", cells?.length, "cells"); + graph.resetCells(cells || [], { fromBrowser: true }); + router.routeAll(); + break; + } + case RouterRequestEvent.Change: { + const { cell } = data; + console.log("Changing cell:", cell.id); + const model = graph.getCell(cell.id); + if (!model) { + console.warn(`Cell with id ${cell.id} not found in worker graph, skipping change event`); + return; + } + if (model.isElement()) { + model.set({ + position: cell.position, + size: cell.size, + }, { + fromBrowser: true + }); + } else { + model.set({ + source: cell.source, + target: cell.target, + vertices: cell.vertices + }, { + fromBrowser: true + }); + } + break; + } + case RouterRequestEvent.Remove: { + const { id } = data; + console.log("Removing cell:", id); + const model = graph.getCell(id); + if (!model) break; + model.remove({ fromBrowser: true }); + break; + } + case RouterRequestEvent.Add: { + const { cell } = data; + console.log("Adding cell:", cell.id); + graph.addCell(cell, { fromBrowser: true }); + break; + } + default: + console.log('Unknown command', command); + break; + } +}; + +await routerLoaded; + +const graph = new dia.Graph({}, { + cellNamespace: { + ...shapes, + diagram: { EntityElement: WorkerEntityElement, RelationshipLink }, + selection: { SelectionElement: WorkerSelectionElement } + } +}); + +const router = new AvoidRouter(graph, { + shapeBufferDistance: 20, + idealNudgingDistance: 10, + portOverflow: 8, + commitTransactions: false +}); + +let changed: any = {}; +let isProcessing = false; + +const debouncedProcessTransaction = util.debounce(() => { + if (isProcessing) return; + isProcessing = true; + + router.avoidRouter.processTransaction(); + setTimeout(() => { + postMessage({ + command: 'routed', + cells: Object.values(changed), + }); + changed = {}; + isProcessing = false; + }, 0); +}, 100); + +router.addGraphListeners(); + +graph.on('change', (cell, opt) => { + if (opt.fromBrowser) { + debouncedProcessTransaction(); + return; + } + changed[cell.id] = cell.toJSON(); +}); + +graph.on('reset', (collection, opt) => { + if (!opt.fromBrowser) return; + debouncedProcessTransaction(); +}); + +graph.on('add', (cell, opt) => { + if (!opt.fromBrowser) return; + debouncedProcessTransaction(); +}); + +graph.on('remove', (cell, opt) => { + delete changed[cell.id]; + if (!opt.fromBrowser) return; + debouncedProcessTransaction(); +}); \ No newline at end of file diff --git a/Website/components/diagramview/diagram-elements/RelationshipLink.ts b/Website/components/diagramview/diagram-elements/RelationshipLink.ts new file mode 100644 index 0000000..b1246ad --- /dev/null +++ b/Website/components/diagramview/diagram-elements/RelationshipLink.ts @@ -0,0 +1,51 @@ +import { dia } from "@joint/core"; + +export const RelationshipLink = dia.Link.define('diagram.RelationshipLink', { + connector: { name: 'jumpover', args: { type: "arc", radius: 10 } }, + markup: [ + { + tagName: 'path', + selector: 'wrapper', + attributes: { + 'fill': 'none', + 'cursor': 'pointer', + 'stroke': 'transparent', + 'stroke-linecap': 'round' + } + }, + { + tagName: 'path', + selector: 'line', + attributes: { + 'fill': 'none', + 'pointer-events': 'none' + } + } + ], + attrs: { + line: { + connection: true, + stroke: 'var(--mui-palette-primary-main)', + strokeWidth: 1, + sourceMarker: { 'type': 'path', 'd': '' }, + targetMarker: { 'type': 'path', 'd': '' } + }, + wrapper: { + connection: true, + strokeWidth: 10, + strokeLinejoin: 'round' + } + } +}); + + +export const RelationshipLinkView = dia.LinkView.extend({ + +}); + +export const createRelationshipLink = (sourceId: dia.Cell.ID, targetId: dia.Cell.ID) => { + return new RelationshipLink({ + source: { id: sourceId }, + target: { id: targetId } + }); +} \ No newline at end of file diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 5027d6a..a78d5fa 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -3,7 +3,10 @@ import React, { createContext, useContext, ReactNode, useReducer, useEffect, use import { createEntity, EntityElement, EntityElementView } from '@/components/diagramview/diagram-elements/EntityElement'; import EntitySelection, { SelectionElement } from '@/components/diagramview/diagram-elements/Selection'; import { SmartLayout } from '@/components/diagramview/layout/SmartLayout'; -import { EntityType } from '@/lib/Types'; +import { EntityType, ExtendedEntityInformationType } from '@/lib/Types'; +import { AvoidRouter } from '@/components/diagramview/avoid-router/shared/avoidrouter'; +import { initializeRouter } from '@/components/diagramview/avoid-router/shared/initialization'; +import { createRelationshipLink, RelationshipLink, RelationshipLinkView } from '@/components/diagramview/diagram-elements/RelationshipLink'; interface DiagramActions { setZoom: (zoom: number) => void; @@ -184,11 +187,18 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { // Refs to store graph and paper instances const graphRef = useRef(null); const paperRef = useRef(null); + useEffect(() => { if (!diagramViewState.canvas.current) return; - const graph = new dia.Graph({}, { cellNamespace: shapes }); + const graph = new dia.Graph({}, { + cellNamespace: { + ...shapes, + diagram: { EntityElement, RelationshipLink }, + selection: { SelectionElement } + } + }); graphRef.current = graph; // Theme-aware colors using MUI CSS variables @@ -217,7 +227,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { snapToGrid: true, frozen: true, async: true, - cellViewNamespace: { ...shapes, diagram: { EntityElement, EntityElementView }, selection: { SelectionElement } } + cellViewNamespace: { ...shapes, diagram: { EntityElement, EntityElementView, RelationshipLink, RelationshipLinkView }, selection: { SelectionElement } } }); paperRef.current = paper; @@ -355,6 +365,16 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { } }; + initializeRouter(graph, paper).then(() => { + const router = new AvoidRouter(graph, { + shapeBufferDistance: 20, + idealNudgingDistance: 10, + }); + + router.addGraphListeners(); + router.routeAll(); + }); + // Add event listeners const canvas = diagramViewState.canvas.current; canvas.addEventListener('mousedown', handleMouseDown); @@ -377,6 +397,79 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }; }, []); + // True if an entity has a lookup attribute targeting targetSchema + const hasLookupTo = (entity: EntityType, targetSchema: string): boolean => { + return entity.Attributes?.some(a => a.AttributeType === "LookupAttribute" && + (a as any).Targets?.some((t: ExtendedEntityInformationType) => + t?.Name?.toLowerCase() === targetSchema.toLowerCase() + ) + ) ?? false; + }; + + // True if an entity declares a relationship to targetSchema + const hasRelationshipTo = (entity: EntityType, targetSchema: string): boolean => { + if (!entity.Relationships) return false; + + const needle = targetSchema.toLowerCase(); + return entity.Relationships.some(r => { + const tableHit = r.TableSchema?.toLowerCase() === needle; + const nameHit = r.Name?.toLowerCase() === needle; + const schemaHit = r.RelationshipSchema?.toLowerCase()?.includes(needle); + return tableHit || nameHit || schemaHit; + }); + }; + + // Decide if two entities should be linked (undirected) + const shouldLinkEntities = (a: EntityType, b: EntityType): boolean => { + if (!a || !b) return false; + if (a.SchemaName === b.SchemaName) return false; + + // Link if either side references the other via lookup or relationship + const aToB = hasLookupTo(a, b.SchemaName) || hasRelationshipTo(a, b.SchemaName); + const bToA = hasLookupTo(b, a.SchemaName) || hasRelationshipTo(b, a.SchemaName); + + return aToB || bToA; + }; + + // Do we already have an (undirected) link between two element ids? + const linkExistsBetween = (graph: dia.Graph, aId: string, bId: string): boolean => { + const links = graph.getLinks(); + return links.some(l => { + const s = l.get('source'); + const t = l.get('target'); + const sId = typeof s?.id === 'string' ? s.id : s?.id?.toString?.(); + const tId = typeof t?.id === 'string' ? t.id : t?.id?.toString?.(); + return (sId === aId && tId === bId) || (sId === bId && tId === aId); + }); + }; + + // Create a simple undirected link between two entity elements + const createUndirectedLink = (graph: dia.Graph, sourceEl: dia.Element, targetEl: dia.Element) => { + const link = createRelationshipLink(sourceEl.id, targetEl.id); + graph.addCell(link); + }; + + // Find + add links between a *new* entity element and all existing ones + const linkNewEntityToExisting = (graph: dia.Graph, newEl: dia.Element) => { + const newData = newEl.get('entityData') as EntityType; + if (!newData) return; + + const existing = graph.getElements().filter(el => + el.get('type') === 'diagram.EntityElement' && el.id !== newEl.id + ); + + for (const el of existing) { + const otherData = el.get('entityData') as EntityType; + if (!otherData) continue; + + if (shouldLinkEntities(newData, otherData)) { + if (!linkExistsBetween(graph, newEl.id.toString(), el.id.toString())) { + createUndirectedLink(graph, newEl, el); + } + } + } + }; + // Context functions const addEntity = (entityData: EntityType, position?: { x: number; y: number }, label?: string) => { if (graphRef.current && paperRef.current) { @@ -417,6 +510,8 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }); graphRef.current.addCell(entity); + + linkNewEntityToExisting(graphRef.current, entity); // Dispatch action to update the entities map in state dispatch({ type: 'ADD_ENTITY_TO_DIAGRAM', payload: entityData }); From 28c2eadf2ce0ec41dcafe7267a2fda87c79fa9b7 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 25 Oct 2025 11:18:59 +0200 Subject: [PATCH 37/51] feat: 1-M M-1 indications on relationships. Click to see relationshipproperties. Minor UI adjustments. --- .../app/api/services/AzureDevOpsService.ts | 2 - .../diagramview/PropertiesPanel.tsx | 63 ++++++++-- .../avoid-router/shared/avoidrouter.ts | 2 +- .../avoid-router/shared/initialization.ts | 3 +- .../avoid-router/worker-thread/worker.ts | 8 +- .../diagram-elements/EntityElement.ts | 1 + .../diagram-elements/RelationshipLink.ts | 70 ++++++++++- .../diagramview/events/SelectObjectEvent.ts | 5 +- .../diagramview/panes/EntitySelectionPane.tsx | 49 +------- .../RelationshipProperties.tsx | 30 +++++ Website/contexts/DiagramViewContext.tsx | 116 ++++++++++++++++-- Website/lib/diagram/DiagramEventBridge.ts | 14 ++- .../models/relationship-information.ts | 8 ++ 13 files changed, 279 insertions(+), 92 deletions(-) create mode 100644 Website/components/diagramview/smaller-components/RelationshipProperties.tsx create mode 100644 Website/lib/diagram/models/relationship-information.ts diff --git a/Website/app/api/services/AzureDevOpsService.ts b/Website/app/api/services/AzureDevOpsService.ts index 24b3ec3..1ed0fd1 100644 --- a/Website/app/api/services/AzureDevOpsService.ts +++ b/Website/app/api/services/AzureDevOpsService.ts @@ -325,8 +325,6 @@ export async function listFileVersions(options: FileVersionOptions): Promise(null); const objectRef = useRef(null); + const userClosedRef = useRef(false); + const [isForcedClosed, setIsForcedClosed] = useState(false); const [isOpen, setIsOpen] = useState(false); - const close = () => { - setIsOpen(false); - setObject(null); - }; + const togglePane = () => { + if (isForcedClosed) { + setIsForcedClosed(false); + setIsOpen(true); + } else { + setIsForcedClosed(true); + setIsOpen(false); + } + } + + useEffect(() => { + userClosedRef.current = isForcedClosed; + }, [isForcedClosed]) useEffect(() => { objectRef.current = object; @@ -30,7 +44,7 @@ export default function PropertiesPanel({ }: IPropertiesPanelProps) { return; } setObject(event); - setIsOpen(true); + setIsOpen(true && !userClosedRef.current); }); return cleanup; @@ -39,19 +53,42 @@ export default function PropertiesPanel({ }: IPropertiesPanelProps) { const getProperties = () => { switch (object?.type) { case 'entity': - return ; + return ; + case 'relationship': + return ; case 'selection': - return ; + return ; } } return ( - - {CloseIcon} + + + {isOpen ? : } + - - {getProperties()} - + { + isOpen && ( + + {getProperties()} + + ) + } ) } diff --git a/Website/components/diagramview/avoid-router/shared/avoidrouter.ts b/Website/components/diagramview/avoid-router/shared/avoidrouter.ts index e676263..d645f8a 100644 --- a/Website/components/diagramview/avoid-router/shared/avoidrouter.ts +++ b/Website/components/diagramview/avoid-router/shared/avoidrouter.ts @@ -34,7 +34,7 @@ export class AvoidRouter { static async load(): Promise { if (AvoidRouter.isLoaded) { - console.log('Avoid library is already initialized'); + console.warn('Avoid library is already initialized'); return; } diff --git a/Website/components/diagramview/avoid-router/shared/initialization.ts b/Website/components/diagramview/avoid-router/shared/initialization.ts index 5a55aa2..e95290f 100644 --- a/Website/components/diagramview/avoid-router/shared/initialization.ts +++ b/Website/components/diagramview/avoid-router/shared/initialization.ts @@ -26,7 +26,7 @@ export async function initializeRouter(graph: dia.Graph, paper: dia.Paper) { }); break; default: - console.log('Unknown response command', command); + console.warn('Unknown response command', command); break; } }; @@ -73,7 +73,6 @@ export async function initializeRouter(graph: dia.Graph, paper: dia.Paper) { }); graph.on('add', (cell) => { - console.log("Adding cell:", cell); routerWorker.postMessage({ command: RouterRequestEvent.Add, cell: cell.toJSON() diff --git a/Website/components/diagramview/avoid-router/worker-thread/worker.ts b/Website/components/diagramview/avoid-router/worker-thread/worker.ts index b5977ed..8c86404 100644 --- a/Website/components/diagramview/avoid-router/worker-thread/worker.ts +++ b/Website/components/diagramview/avoid-router/worker-thread/worker.ts @@ -32,20 +32,16 @@ const WorkerSelectionElement = dia.Element.define('selection.SelectionElement', onmessage = async(e) => { await routerLoaded; - console.log("Worker received message:", e.data); - const { command, ...data } = e.data; // Remove array destructuring switch (command) { case RouterRequestEvent.Reset: { const { cells } = data; - console.log("Resetting graph with", cells?.length, "cells"); graph.resetCells(cells || [], { fromBrowser: true }); router.routeAll(); break; } case RouterRequestEvent.Change: { const { cell } = data; - console.log("Changing cell:", cell.id); const model = graph.getCell(cell.id); if (!model) { console.warn(`Cell with id ${cell.id} not found in worker graph, skipping change event`); @@ -71,7 +67,6 @@ onmessage = async(e) => { } case RouterRequestEvent.Remove: { const { id } = data; - console.log("Removing cell:", id); const model = graph.getCell(id); if (!model) break; model.remove({ fromBrowser: true }); @@ -79,12 +74,11 @@ onmessage = async(e) => { } case RouterRequestEvent.Add: { const { cell } = data; - console.log("Adding cell:", cell.id); graph.addCell(cell, { fromBrowser: true }); break; } default: - console.log('Unknown command', command); + console.warn('Unknown command', command); break; } }; diff --git a/Website/components/diagramview/diagram-elements/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts index dc7bf86..4761652 100644 --- a/Website/components/diagramview/diagram-elements/EntityElement.ts +++ b/Website/components/diagramview/diagram-elements/EntityElement.ts @@ -116,6 +116,7 @@ export const EntityElementView = dia.ElementView.extend({ export const EntityElement = dia.Element.define('diagram.EntityElement', { size: { width: 120, height: 80 }, + z: 10, attrs: { foreignObject: { width: 'calc(w)', diff --git a/Website/components/diagramview/diagram-elements/RelationshipLink.ts b/Website/components/diagramview/diagram-elements/RelationshipLink.ts index b1246ad..4bd45d1 100644 --- a/Website/components/diagramview/diagram-elements/RelationshipLink.ts +++ b/Website/components/diagramview/diagram-elements/RelationshipLink.ts @@ -1,7 +1,10 @@ +import { diagramEvents } from "@/lib/diagram/DiagramEventBridge"; +import { RelationshipInformation } from "@/lib/diagram/models/relationship-information"; import { dia } from "@joint/core"; export const RelationshipLink = dia.Link.define('diagram.RelationshipLink', { connector: { name: 'jumpover', args: { type: "arc", radius: 10 } }, + z: 1, markup: [ { tagName: 'path', @@ -26,9 +29,7 @@ export const RelationshipLink = dia.Link.define('diagram.RelationshipLink', { line: { connection: true, stroke: 'var(--mui-palette-primary-main)', - strokeWidth: 1, - sourceMarker: { 'type': 'path', 'd': '' }, - targetMarker: { 'type': 'path', 'd': '' } + strokeWidth: 1 }, wrapper: { connection: true, @@ -41,6 +42,29 @@ export const RelationshipLink = dia.Link.define('diagram.RelationshipLink', { export const RelationshipLinkView = dia.LinkView.extend({ + events: { + 'pointerdown': 'onPointerDown', + 'mouseenter': 'onMouseEnter', + 'mouseleave': 'onMouseLeave', + }, + + onMouseEnter: function() { + this.model.attr('line/strokeWidth', 2); + }, + + onMouseLeave: function() { + this.model.attr('line/strokeWidth', 1); + }, + + onPointerDown: function(evt: PointerEvent) { + evt.stopPropagation(); + evt.preventDefault(); + + diagramEvents.dispatchRelationshipSelect( + String(this.model.id), + this.model.get('relationshipInformation') + ); + } }); export const createRelationshipLink = (sourceId: dia.Cell.ID, targetId: dia.Cell.ID) => { @@ -48,4 +72,44 @@ export const createRelationshipLink = (sourceId: dia.Cell.ID, targetId: dia.Cell source: { id: sourceId }, target: { id: targetId } }); +} + +const circleMarker = { + type: 'circle', + r: 3, + cx: 4, + z: 1, + fill: 'var(--mui-palette-background-default)', + stroke: 'var(--mui-palette-primary-main)', + 'stroke-width': 1 +}; + +// Create a directed relationship link with proper markers +export const createDirectedRelationshipLink = ( + sourceId: dia.Cell.ID, + targetId: dia.Cell.ID, + direction: '1-M' | 'M-1' | 'M-M', + relationshipInformation: RelationshipInformation +) => { + const link = new RelationshipLink({ + source: { id: sourceId }, + target: { id: targetId }, + relationshipInformation + }); + + // Set markers based on relationship direction + switch (direction) { + case '1-M': + link.attr('line/targetMarker', circleMarker); + break; + case 'M-1': + link.attr('line/sourceMarker', circleMarker); + break; + case 'M-M': + link.attr('line/sourceMarker', circleMarker); + link.attr('line/targetMarker', circleMarker); + break; + } + + return link; } \ No newline at end of file diff --git a/Website/components/diagramview/events/SelectObjectEvent.ts b/Website/components/diagramview/events/SelectObjectEvent.ts index cccfe8f..ca65b8e 100644 --- a/Website/components/diagramview/events/SelectObjectEvent.ts +++ b/Website/components/diagramview/events/SelectObjectEvent.ts @@ -1,7 +1,8 @@ +import { RelationshipInformation } from "@/lib/diagram/models/relationship-information"; import { EntityType } from "@/lib/Types"; export type SelectObjectEvent = { - type: 'none' | 'entity' | 'selection'; + type: 'none' | 'entity' | 'selection' | 'relationship'; objectId: string | null; - data?: EntityType[]; + data?: EntityType[] | RelationshipInformation[]; } \ No newline at end of file diff --git a/Website/components/diagramview/panes/EntitySelectionPane.tsx b/Website/components/diagramview/panes/EntitySelectionPane.tsx index 1b5354b..8ac088b 100644 --- a/Website/components/diagramview/panes/EntitySelectionPane.tsx +++ b/Website/components/diagramview/panes/EntitySelectionPane.tsx @@ -30,29 +30,11 @@ export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) const { groups } = useDatamodelData(); const { addEntity, isEntityInDiagram } = useDiagramView(); const [expandedGroups, setExpandedGroups] = useState>(new Set()); - const [selectedEntities, setSelectedEntities] = useState([]); const handleEntitySelect = useCallback((entity: EntityType) => { - setSelectedEntities(prev => { - if (prev.find(e => e.SchemaName === entity.SchemaName)) { - return prev; - } - return [...prev, entity]; - }); - }, []); - - const handleEntityDeselect = useCallback((entity: EntityType) => { - setSelectedEntities(prev => prev.filter(e => e.SchemaName !== entity.SchemaName)); + addEntity(entity, undefined, entity.DisplayName); }, []); - const handleSubmit = () => { - selectedEntities.forEach(entity => { - addEntity(entity, undefined, entity.DisplayName); - }); - setSelectedEntities([]); - onClose(); - } - const handleGroupToggle = useCallback((groupName: string) => { setExpandedGroups(prev => { const newExpanded = new Set(prev); @@ -87,29 +69,6 @@ export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) - - - - - Selected: - - {selectedEntities.map((entity) => ( - : } deleteIcon={} - label={entity.DisplayName} - onDelete={() => handleEntityDeselect(entity)} /> - ))} - - {groups.length === 0 ? ( @@ -131,12 +90,6 @@ export const EntitySelectionPane =({ open, onClose }: EntitySelectionPaneProps) ))} )} - - - - ); diff --git a/Website/components/diagramview/smaller-components/RelationshipProperties.tsx b/Website/components/diagramview/smaller-components/RelationshipProperties.tsx new file mode 100644 index 0000000..e4fbf29 --- /dev/null +++ b/Website/components/diagramview/smaller-components/RelationshipProperties.tsx @@ -0,0 +1,30 @@ +import { RelationshipInformation } from '@/lib/diagram/models/relationship-information'; +import { Box, Paper, Typography } from '@mui/material'; +import React from 'react' + +interface IRelationshipPropertiesProps { + relationships: RelationshipInformation[]; +} + +const RelationshipProperties = ({ relationships }: IRelationshipPropertiesProps) => { + return ( + + Relationship Properties + {relationships.map((rel, index) => ( + + + {rel.sourceEntityDisplayName} + {rel.sourceEntitySchemaName} + + {rel.RelationshipType} + + {rel.targetEntityDisplayName} + {rel.targetEntitySchemaName} + + + ))} + + ) +} + +export default RelationshipProperties; \ No newline at end of file diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index a78d5fa..30e8667 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -6,7 +6,8 @@ import { SmartLayout } from '@/components/diagramview/layout/SmartLayout'; import { EntityType, ExtendedEntityInformationType } from '@/lib/Types'; import { AvoidRouter } from '@/components/diagramview/avoid-router/shared/avoidrouter'; import { initializeRouter } from '@/components/diagramview/avoid-router/shared/initialization'; -import { createRelationshipLink, RelationshipLink, RelationshipLinkView } from '@/components/diagramview/diagram-elements/RelationshipLink'; +import { createRelationshipLink, createDirectedRelationshipLink, RelationshipLink, RelationshipLinkView } from '@/components/diagramview/diagram-elements/RelationshipLink'; +import { RelationshipInformation } from '@/lib/diagram/models/relationship-information'; interface DiagramActions { setZoom: (zoom: number) => void; @@ -397,6 +398,75 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }; }, []); + // Determine the relationship direction between two entities + const getRelationshipDirection = (sourceEntity: EntityType, targetEntity: EntityType): '1-M' | 'M-1' | 'M-M' | null => { + if (!sourceEntity || !targetEntity) return null; + + let sourceToTargetType: 'none' | '1' | 'M' = 'none'; + let targetToSourceType: 'none' | '1' | 'M' = 'none'; + + // Check if source has a lookup to target (source is "many", target is "one") + if (hasLookupTo(sourceEntity, targetEntity.SchemaName)) { + sourceToTargetType = 'M'; + targetToSourceType = '1'; + } + + // Check if target has a lookup to source (target is "many", source is "one") + if (hasLookupTo(targetEntity, sourceEntity.SchemaName)) { + targetToSourceType = 'M'; + sourceToTargetType = '1'; + } + + // Check relationships from source entity + if (sourceEntity.Relationships) { + const sourceRelationship = sourceEntity.Relationships.find(r => + r.TableSchema?.toLowerCase() === targetEntity.SchemaName.toLowerCase() + ); + + if (sourceRelationship) { + if (sourceRelationship.IsManyToMany) { + return 'M-M'; + } + // For 1-to-many relationships defined in the source entity, + // the source is typically the "1" side and target is the "many" side + if (sourceToTargetType === 'none') { + sourceToTargetType = '1'; + targetToSourceType = 'M'; + } + } + } + + // Check relationships from target entity + if (targetEntity.Relationships) { + const targetRelationship = targetEntity.Relationships.find(r => + r.TableSchema?.toLowerCase() === sourceEntity.SchemaName.toLowerCase() + ); + + if (targetRelationship) { + if (targetRelationship.IsManyToMany) { + return 'M-M'; + } + // For 1-to-many relationships defined in the target entity, + // the target is typically the "1" side and source is the "many" side + if (targetToSourceType === 'none') { + targetToSourceType = '1'; + sourceToTargetType = 'M'; + } + } + } + + // Determine final direction + if (sourceToTargetType === '1' && targetToSourceType === 'M') { + return '1-M'; // Source is one, target is many + } else if (sourceToTargetType === 'M' && targetToSourceType === '1') { + return 'M-1'; // Source is many, target is one + } else if (sourceToTargetType === 'M' && targetToSourceType === 'M') { + return 'M-M'; // Both are many + } + + return null; // Unable to determine or no relationship + }; + // True if an entity has a lookup attribute targeting targetSchema const hasLookupTo = (entity: EntityType, targetSchema: string): boolean => { return entity.Attributes?.some(a => a.AttributeType === "LookupAttribute" && @@ -443,10 +513,31 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }); }; - // Create a simple undirected link between two entity elements - const createUndirectedLink = (graph: dia.Graph, sourceEl: dia.Element, targetEl: dia.Element) => { - const link = createRelationshipLink(sourceEl.id, targetEl.id); - graph.addCell(link); + // Create a directed link between two entity elements based on their relationship + const createDirectedLink = (graph: dia.Graph, sourceEl: dia.Element, targetEl: dia.Element) => { + const sourceData = sourceEl.get('entityData') as EntityType; + const targetData = targetEl.get('entityData') as EntityType; + + if (!sourceData || !targetData) return; + + const direction = getRelationshipDirection(sourceData, targetData); + + if (direction) { + const info: RelationshipInformation = { + sourceEntitySchemaName: sourceData.SchemaName, + sourceEntityDisplayName: sourceData.DisplayName, + targetEntitySchemaName: targetData.SchemaName, + targetEntityDisplayName: targetData.DisplayName, + RelationshipType: direction, + RelationshipSchemaName: "" // TODO: found inside the + }; + const link = createDirectedRelationshipLink(sourceEl.id, targetEl.id, direction, info); + graph.addCell(link); + } else { + // Fallback to undirected link if direction cannot be determined + const link = createRelationshipLink(sourceEl.id, targetEl.id); + graph.addCell(link); + } }; // Find + add links between a *new* entity element and all existing ones @@ -464,7 +555,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { if (shouldLinkEntities(newData, otherData)) { if (!linkExistsBetween(graph, newEl.id.toString(), el.id.toString())) { - createUndirectedLink(graph, newEl, el); + createDirectedLink(graph, newEl, el); } } } @@ -499,11 +590,16 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { entityY = centerPaperPoint.y; } + // Snap entity position to grid (grid size is 20px) + const gridSize = 20; + const snappedX = Math.round((entityX - 60) / gridSize) * gridSize; // Center the entity (120px width) + const snappedY = Math.round((entityY - 40) / gridSize) * gridSize; // Center the entity (80px height) + const entityLabel = label || `Entity ${graphRef.current.getCells().length + 1}`; // Create the new entity using our custom EntityElement const entity = createEntity({ - position: { x: entityX - 60, y: entityY - 40 }, // Center the entity (120x80 default size) + position: { x: snappedX, y: snappedY }, title: entityLabel, size: { width: 120, height: 80 }, entityData @@ -662,12 +758,6 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { const smartLayout = new SmartLayout(paperRef.current, layoutEntities); smartLayout.applyLayout(); - console.log('Smart layout applied to', layoutEntities.length, 'entities'); - - // Optional: Log connection statistics for debugging - const stats = smartLayout.getConnectionStats(); - console.log('Entity connection stats:', stats); - // Recalculate selection bounding box after layout change if (selectionRef.current) { selectionRef.current.recalculateBoundingBox(); diff --git a/Website/lib/diagram/DiagramEventBridge.ts b/Website/lib/diagram/DiagramEventBridge.ts index f60c178..c5ad648 100644 --- a/Website/lib/diagram/DiagramEventBridge.ts +++ b/Website/lib/diagram/DiagramEventBridge.ts @@ -1,5 +1,6 @@ import { SelectObjectEvent } from '@/components/diagramview/events/SelectObjectEvent'; -import { EntityType } from '../Types'; +import { EntityType, RelationshipType } from '../Types'; +import { RelationshipInformation } from './models/relationship-information'; /** * Event bridge class that provides a simple interface for both React and Joint.js components @@ -28,6 +29,17 @@ export class DiagramEventBridge { window.dispatchEvent(event); } + dispatchRelationshipSelect(relationshipId: string, relationshipData: RelationshipInformation) { + const event = new CustomEvent('selectObject', { + detail: { + type: 'relationship', + objectId: relationshipId, + data: [relationshipData] + } + }); + window.dispatchEvent(event); + } + dispatchSelectionChange(entities: EntityType[]) { const event = new CustomEvent('selectObject', { detail: { diff --git a/Website/lib/diagram/models/relationship-information.ts b/Website/lib/diagram/models/relationship-information.ts new file mode 100644 index 0000000..d9329e6 --- /dev/null +++ b/Website/lib/diagram/models/relationship-information.ts @@ -0,0 +1,8 @@ +export type RelationshipInformation = { + sourceEntitySchemaName: string, + sourceEntityDisplayName: string, + targetEntitySchemaName: string, + targetEntityDisplayName: string, + RelationshipType: '1-M' | 'M-1' | 'M-M', + RelationshipSchemaName: string, +} \ No newline at end of file From 4fcbef67b8f3ba56ebdc59361d3c91e153cb8819 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 25 Oct 2025 11:50:31 +0200 Subject: [PATCH 38/51] feat: selfreferencing relationship (minor bug where libavoid likes to move the out port down) --- .../diagram-elements/EntityElement.ts | 33 ++++++++++++ .../diagram-elements/RelationshipLink.ts | 12 ++++- Website/contexts/DiagramViewContext.tsx | 50 ++++++++++++++++--- .../models/relationship-information.ts | 2 +- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/Website/components/diagramview/diagram-elements/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts index 4761652..956a5d7 100644 --- a/Website/components/diagramview/diagram-elements/EntityElement.ts +++ b/Website/components/diagramview/diagram-elements/EntityElement.ts @@ -147,6 +147,39 @@ export const EntityElement = dia.Element.define('diagram.EntityElement', { wordBreak: 'break-word' } } + }, + ports: { + groups: { + top: { + position: { name: 'top' }, + label: { position: 'outside' }, + markup: [{ tagName: 'circle', selector: 'portBody', attributes: { r: 4 } }], + attrs: { + portBody: { + magnet: 'true', + stroke: '#888', + fill: '#fff' + } + } + }, + right: { + position: { name: 'right' }, + label: { position: 'outside' }, + markup: [{ tagName: 'circle', selector: 'portBody', attributes: { r: 4 } }], + attrs: { + portBody: { + magnet: 'true', + stroke: '#888', + fill: '#fff' + } + } + } + }, + + items: [ + { id: 'self-in', group: 'top', attrs: { portBody: { display: 'none' } } }, + { id: 'self-out', group: 'right', attrs: { portBody: { display: 'none' } } } + ] } }, { markup: util.svg/* xml */` diff --git a/Website/components/diagramview/diagram-elements/RelationshipLink.ts b/Website/components/diagramview/diagram-elements/RelationshipLink.ts index 4bd45d1..b03b4ce 100644 --- a/Website/components/diagramview/diagram-elements/RelationshipLink.ts +++ b/Website/components/diagramview/diagram-elements/RelationshipLink.ts @@ -88,7 +88,7 @@ const circleMarker = { export const createDirectedRelationshipLink = ( sourceId: dia.Cell.ID, targetId: dia.Cell.ID, - direction: '1-M' | 'M-1' | 'M-M', + direction: '1-M' | 'M-1' | 'M-M' | 'SELF', relationshipInformation: RelationshipInformation ) => { const link = new RelationshipLink({ @@ -109,6 +109,16 @@ export const createDirectedRelationshipLink = ( link.attr('line/sourceMarker', circleMarker); link.attr('line/targetMarker', circleMarker); break; + case 'SELF': + // Self-referencing relationship - create a loop with markers + link.attr('line/sourceMarker', circleMarker); + link.attr('line/targetMarker', circleMarker); + break; + } + + if (sourceId === targetId) { + link.set('source', { id: sourceId, port: 'self-out' }); + link.set('target', { id: targetId, port: 'self-in' }); } return link; diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 30e8667..5cf418c 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -399,9 +399,23 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }, []); // Determine the relationship direction between two entities - const getRelationshipDirection = (sourceEntity: EntityType, targetEntity: EntityType): '1-M' | 'M-1' | 'M-M' | null => { + const getRelationshipDirection = (sourceEntity: EntityType, targetEntity: EntityType): '1-M' | 'M-1' | 'M-M' | 'SELF' | null => { if (!sourceEntity || !targetEntity) return null; + // Handle self-referencing relationships + if (sourceEntity.SchemaName === targetEntity.SchemaName) { + // Check if entity has self-referencing lookup or relationship + const hasSelfLookup = hasLookupTo(sourceEntity, sourceEntity.SchemaName); + const hasSelfRelationship = sourceEntity.Relationships?.some(r => + r.TableSchema?.toLowerCase() === sourceEntity.SchemaName.toLowerCase() + ); + + if (hasSelfLookup || hasSelfRelationship) { + return 'SELF'; + } + return null; + } + let sourceToTargetType: 'none' | '1' | 'M' = 'none'; let targetToSourceType: 'none' | '1' | 'M' = 'none'; @@ -489,10 +503,17 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }); }; - // Decide if two entities should be linked (undirected) + // Decide if two entities should be linked (including self-referencing) const shouldLinkEntities = (a: EntityType, b: EntityType): boolean => { if (!a || !b) return false; - if (a.SchemaName === b.SchemaName) return false; + + // Allow self-referencing relationships + if (a.SchemaName === b.SchemaName) { + // Check if entity has self-referencing lookup or relationship + const hasSelfLookup = hasLookupTo(a, a.SchemaName); + const hasSelfRelationship = hasRelationshipTo(a, a.SchemaName); + return hasSelfLookup || hasSelfRelationship; + } // Link if either side references the other via lookup or relationship const aToB = hasLookupTo(a, b.SchemaName) || hasRelationshipTo(a, b.SchemaName); @@ -501,7 +522,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { return aToB || bToA; }; - // Do we already have an (undirected) link between two element ids? + // Do we already have a link between two element ids (including self-referencing)? const linkExistsBetween = (graph: dia.Graph, aId: string, bId: string): boolean => { const links = graph.getLinks(); return links.some(l => { @@ -509,6 +530,13 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { const t = l.get('target'); const sId = typeof s?.id === 'string' ? s.id : s?.id?.toString?.(); const tId = typeof t?.id === 'string' ? t.id : t?.id?.toString?.(); + + // Handle self-referencing links (same source and target) + if (aId === bId) { + return sId === aId && tId === bId; + } + + // Handle regular links (bidirectional check) return (sId === aId && tId === bId) || (sId === bId && tId === aId); }); }; @@ -529,7 +557,7 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { targetEntitySchemaName: targetData.SchemaName, targetEntityDisplayName: targetData.DisplayName, RelationshipType: direction, - RelationshipSchemaName: "" // TODO: found inside the + RelationshipSchemaName: "" // TODO: found inside the relationship definitions (but what about lookups?) }; const link = createDirectedRelationshipLink(sourceEl.id, targetEl.id, direction, info); graph.addCell(link); @@ -540,11 +568,21 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { } }; - // Find + add links between a *new* entity element and all existing ones + // Find + add links between a *new* entity element and all existing ones (including self-referencing) const linkNewEntityToExisting = (graph: dia.Graph, newEl: dia.Element) => { const newData = newEl.get('entityData') as EntityType; if (!newData) return; + // Check for self-referencing relationship first + if (shouldLinkEntities(newData, newData)) { + console.log("Self-referencing relationship detected"); + // Entity has self-referencing relationship + if (!linkExistsBetween(graph, newEl.id.toString(), newEl.id.toString())) { + createDirectedLink(graph, newEl, newEl); + } + } + + // Then check relationships with other entities const existing = graph.getElements().filter(el => el.get('type') === 'diagram.EntityElement' && el.id !== newEl.id ); diff --git a/Website/lib/diagram/models/relationship-information.ts b/Website/lib/diagram/models/relationship-information.ts index d9329e6..a88055e 100644 --- a/Website/lib/diagram/models/relationship-information.ts +++ b/Website/lib/diagram/models/relationship-information.ts @@ -3,6 +3,6 @@ export type RelationshipInformation = { sourceEntityDisplayName: string, targetEntitySchemaName: string, targetEntityDisplayName: string, - RelationshipType: '1-M' | 'M-1' | 'M-M', + RelationshipType: '1-M' | 'M-1' | 'M-M' | 'SELF', RelationshipSchemaName: string, } \ No newline at end of file From bcf1f8a6add5d162da669fd39a592691c313520d Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 25 Oct 2025 12:05:09 +0200 Subject: [PATCH 39/51] fix: selection transoformation fixed to be relative to the paper transformation matrix --- .../diagramview/diagram-elements/Selection.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/Website/components/diagramview/diagram-elements/Selection.ts b/Website/components/diagramview/diagram-elements/Selection.ts index f7905d9..a209962 100644 --- a/Website/components/diagramview/diagram-elements/Selection.ts +++ b/Website/components/diagramview/diagram-elements/Selection.ts @@ -48,10 +48,18 @@ export default class EntitySelection { this.isDragging = true; this.dragStart = new g.Point(x, y); - // Create transient overlay rect directly in the paper’s SVG layer + // Get the paper's current transformation matrix + const matrix = this.paper.matrix(); + + // Apply transformation to get visual coordinates relative to the paper's SVG + const transformedX = matrix.a * x + matrix.e; + const transformedY = matrix.d * y + matrix.f; + + // Create transient overlay rect directly in the paper's SVG layer const svgRoot = this.paper.svg as unknown as SVGSVGElement; const rect = V('rect', { - x, y, + x: transformedX, + y: transformedY, width: 1, height: 1, 'pointer-events': 'none', @@ -72,10 +80,19 @@ export default class EntitySelection { const p0 = this.dragStart; const p1 = new g.Point(x, y); - const minX = Math.min(p0.x, p1.x); - const minY = Math.min(p0.y, p1.y); - const width = Math.max(1, Math.abs(p1.x - p0.x)); - const height = Math.max(1, Math.abs(p1.y - p0.y)); + // Get the paper's current transformation matrix + const matrix = this.paper.matrix(); + + // Apply transformation to get visual coordinates relative to the paper's SVG + const transformedP0X = matrix.a * p0.x + matrix.e; + const transformedP0Y = matrix.d * p0.y + matrix.f; + const transformedP1X = matrix.a * p1.x + matrix.e; + const transformedP1Y = matrix.d * p1.y + matrix.f; + + const minX = Math.min(transformedP0X, transformedP1X); + const minY = Math.min(transformedP0Y, transformedP1Y); + const width = Math.max(1, Math.abs(transformedP1X - transformedP0X)); + const height = Math.max(1, Math.abs(transformedP1Y - transformedP0Y)); this.overlayRect.attr({ x: minX, y: minY, width, height From 55b7713a547fd00e193e96005790fcc661e28b09 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sun, 26 Oct 2025 11:14:45 +0100 Subject: [PATCH 40/51] feat: show multiple relationships between entities in relationship link --- Website/CLAUDE.md | 208 ++++++++++++++++++ .../diagramview/PropertiesPanel.tsx | 2 +- .../diagram-elements/RelationshipLink.ts | 15 +- .../RelationshipProperties.tsx | 60 ++++- Website/contexts/DiagramViewContext.tsx | 169 +++++++++++--- Website/lib/diagram/DiagramEventBridge.ts | 9 +- .../models/relationship-information.ts | 5 + .../lib/diagram/models/serialized-entity.ts | 2 + 8 files changed, 416 insertions(+), 54 deletions(-) create mode 100644 Website/CLAUDE.md diff --git a/Website/CLAUDE.md b/Website/CLAUDE.md new file mode 100644 index 0000000..1414484 --- /dev/null +++ b/Website/CLAUDE.md @@ -0,0 +1,208 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Data Model Viewer is a Next.js 15 application for visualizing Dataverse data models. It features an interactive diagram editor built on JointJS with custom routing using libavoid-js, Azure DevOps integration for persistence, and comprehensive metadata visualization. + +## Development Commands + +### Setup +```bash +npm i +``` + +Required environment variables in `.env.local`: +- `WebsitePassword` - Basic auth password +- `WebsiteSessionSecret` - Session encryption secret +- `ADO_PROJECT_NAME` - Azure DevOps project name +- `ADO_ORGANIZATION_URL` - Azure DevOps organization URL +- `ADO_REPOSITORY_NAME` - Repository name for diagram storage +- `AZURE_CLI_AUTHENTICATION_ENABLED` - Set to `true` for local dev +- `ADO_PAT` - Personal Access Token for Azure DevOps (generate at DevOps settings) + +### Development +```bash +npm run dev # Start development server +npm run build # Build production bundle +npm run start # Start production server +npm run lint # Run ESLint +npm run prepipeline # Copy stub files (runs before pipeline build) +``` + +Note: The build process includes a `postbuild` script that creates required standalone directories for Next.js 15 deployment compatibility. + +## Architecture + +### Core Technology Stack +- **Next.js 15** with App Router and React 19 +- **JointJS (@joint/core)** for diagram rendering and manipulation +- **libavoid-js** for intelligent relationship routing (runs in Web Worker) +- **MUI (Material-UI v7)** for UI components +- **Tailwind CSS 4** for styling +- **Azure DevOps REST API** for diagram persistence + +### Context Providers (app/layout.tsx) +Application uses nested context providers in this order: +1. `AuthProvider` - Session management and Azure DevOps authentication +2. `SettingsProvider` - User preferences and UI settings +3. `DatamodelDataProvider` - Dataverse metadata (entities, relationships, attributes) +4. `SidebarProvider` - UI sidebar state +5. `SnackbarProvider` - Toast notifications + +### Diagram Architecture + +**Key Pattern**: Diagram uses JointJS for rendering with a Web Worker for routing calculations. + +#### DiagramViewContext (`contexts/DiagramViewContext.tsx`) +- Central state management for the diagram canvas +- Maintains JointJS `dia.Graph` and `dia.Paper` instances +- Manages zoom, pan, entity selection, and entity lifecycle +- Provides actions: `addEntity`, `removeEntity`, `selectEntity`, `applySmartLayout`, etc. +- Tracks `entitiesInDiagram` Map for quick lookups + +#### Custom JointJS Elements +**EntityElement** (`components/diagramview/diagram-elements/EntityElement.ts`): +- Custom JointJS element representing Dataverse entities +- Renders entity name, icon, and connection ports +- Stores `entityData` (EntityType) in attributes +- Uses custom `EntityElementView` for DOM interactions + +**RelationshipLink** (`components/diagramview/diagram-elements/RelationshipLink.ts`): +- Custom JointJS link for entity relationships +- Stores `relationshipInformation` in attributes +- Supports both directed and undirected relationships +- Integrates with libavoid router for auto-routing + +**Selection** (`components/diagramview/diagram-elements/Selection.ts`): +- Multi-entity selection boundary element +- Handles group transformations (move, scale, rotate) +- Calculates bounding boxes and applies transformations relative to paper matrix + +#### Avoid Router (Orthogonal Routing) +**Location**: `components/diagramview/avoid-router/` + +The diagram uses libavoid-js (C++ library compiled to WebAssembly) for intelligent orthogonal routing: + +- **Web Worker** (`avoid-router/worker-thread/worker.ts`): Runs routing calculations off the main thread +- **AvoidRouter** (`avoid-router/shared/avoidrouter.ts`): Manages router state and communicates with worker +- **Initialization** (`avoid-router/shared/initialization.ts`): Sets up router with diagram graph + +**Key Concept**: Main thread sends graph changes to worker, worker calculates routes using libavoid, results sent back to update link vertices. + +#### Diagram Event Communication + +**DiagramEventBridge** (`lib/diagram/DiagramEventBridge.ts`): +- Singleton pattern for cross-component communication +- Bridges JointJS (non-React) and React components +- Uses browser CustomEvents for type-safe messaging +- Event types: `selectObject`, `entityContextMenu` +- React components use `onSelectionEvent()` and `onContextMenuEvent()` convenience methods + +**Pattern**: JointJS event listeners dispatch through DiagramEventBridge → React components listen via useEffect hooks. + +### Serialization & Persistence + +**DiagramSerializationService** (`lib/diagram/services/diagram-serialization.ts`): +- Converts JointJS graph to `SerializedDiagram` format +- Stores entity positions, sizes, zoom, pan state + +**DiagramDeserializationService** (`lib/diagram/services/diagram-deserialization.ts`): +- Reconstructs JointJS graph from `SerializedDiagram` +- Recreates EntityElements and RelationshipLinks with proper routing + +**AzureDevOpsService** (`app/api/services/AzureDevOpsService.ts`): +- Handles all Git operations for diagram storage +- Methods: `createFile`, `loadFile`, `listFiles`, `getVersions` +- Uses managed identity or PAT authentication + +### Type System + +**Core Types** (`lib/Types.ts`): +- `EntityType`: Dataverse entity metadata (attributes, relationships, security roles, keys) +- `RelationshipType`: N:1, 1:N, N:N relationship definitions +- `AttributeType`: Polymorphic attribute types (String, Lookup, Boolean, etc.) +- `SolutionType`, `SolutionComponentType`: Solution component tracking + +**Diagram Types**: +- `SerializedDiagram` (`lib/diagram/models/serialized-diagram.ts`): Persistence format +- `SerializedEntity` (`lib/diagram/models/serialized-entity.ts`): Entity position/size/label +- `RelationshipInformation` (`lib/diagram/models/relationship-information.ts`): Relationship display data + +### Component Organization + +``` +components/ + diagramview/ # Diagram canvas and interactions + diagram-elements/ # Custom JointJS elements (EntityElement, RelationshipLink, Selection) + avoid-router/ # libavoid routing with worker thread + layout/ # SmartLayout for auto-arranging entities + panes/ # Side panels (entity list, properties) + modals/ # Dialogs (save, load, version history) + datamodelview/ # Metadata viewer for entities/attributes + entity/ # Entity detail components + attributes/ # Attribute type-specific renderers + insightsview/ # Analytics and reporting + shared/ # Reusable UI components (Layout, Sidebar) +``` + +### API Routes + +All API routes are in `app/api/`: + +**Authentication**: +- `POST /api/auth/login` - Password authentication +- `GET /api/auth/logout` - Session termination +- `GET /api/auth/session` - Session validation + +**Diagram Operations**: +- `GET /api/diagram/list` - List saved diagrams from ADO +- `POST /api/diagram/load` - Load diagram JSON from ADO +- `POST /api/diagram/save` - Persist diagram to ADO Git repo +- `GET /api/diagram/versions` - Get version history for a diagram +- `POST /api/diagram/version` - Load specific version +- `GET /api/diagram/repository-info` - Get ADO repository details + +**Other**: +- `POST /api/markdown` - Render markdown content +- `GET /api/version` - Application version info + +## Key Development Patterns + +### Adding Entities to Diagram +1. Get entity data from `DatamodelDataContext` +2. Call `diagramContext.addEntity(entityData, position)` +3. Context creates `EntityElement` via `createEntity()` +4. Element added to graph → triggers router update in worker +5. DiagramEventBridge dispatches selection event if needed + +### Handling Entity Selection +1. User clicks entity → JointJS 'element:pointerclick' event +2. EntityElementView dispatches through DiagramEventBridge +3. React components listening via `diagramEvents.onSelectionEvent()` +4. PropertiesPanel updates to show entity details + +### Relationship Routing Flow +1. Entity moved on canvas +2. DiagramViewContext detects change +3. Worker receives RouterRequestEvent.Change message +4. libavoid calculates new route avoiding obstacles +5. Worker returns updated vertices +6. Main thread updates link vertices on graph + +### Working with Azure DevOps +Authentication uses either: +- **Local dev**: Azure CLI with PAT token (`AZURE_CLI_AUTHENTICATION_ENABLED=true`) +- **Production**: Managed Identity (`ManagedIdentityAuthService.ts`) + +File operations always specify branch (default: 'main') and commit messages. + +## Important Notes + +- **Path aliases**: `@/*` maps to root directory (see `tsconfig.json`) +- **Next.js config**: Uses standalone output mode for containerized deployment +- **Worker thread**: libavoid runs in Web Worker - avoid blocking main thread with routing logic +- **Selection transformations**: Must be calculated relative to paper transformation matrix (see `Selection.ts:applyTransformation`) +- **Entity deduplication**: Always check `diagramContext.isEntityInDiagram()` before adding +- **JointJS integration**: Custom elements defined with `dia.Element.define()`, custom views with `dia.ElementView.extend()` diff --git a/Website/components/diagramview/PropertiesPanel.tsx b/Website/components/diagramview/PropertiesPanel.tsx index 9225e98..d0cd5de 100644 --- a/Website/components/diagramview/PropertiesPanel.tsx +++ b/Website/components/diagramview/PropertiesPanel.tsx @@ -84,7 +84,7 @@ export default function PropertiesPanel({ }: IPropertiesPanelProps) { { isOpen && ( - + {getProperties()} ) diff --git a/Website/components/diagramview/diagram-elements/RelationshipLink.ts b/Website/components/diagramview/diagram-elements/RelationshipLink.ts index b03b4ce..c75011e 100644 --- a/Website/components/diagramview/diagram-elements/RelationshipLink.ts +++ b/Website/components/diagramview/diagram-elements/RelationshipLink.ts @@ -60,9 +60,12 @@ export const RelationshipLinkView = dia.LinkView.extend({ evt.stopPropagation(); evt.preventDefault(); + // Get the relationships array from the model + const relationships = this.model.get('relationshipInformationList') || [this.model.get('relationshipInformation')].filter(Boolean); + diagramEvents.dispatchRelationshipSelect( String(this.model.id), - this.model.get('relationshipInformation') + relationships ); } }); @@ -86,15 +89,17 @@ const circleMarker = { // Create a directed relationship link with proper markers export const createDirectedRelationshipLink = ( - sourceId: dia.Cell.ID, - targetId: dia.Cell.ID, + sourceId: dia.Cell.ID, + targetId: dia.Cell.ID, direction: '1-M' | 'M-1' | 'M-M' | 'SELF', - relationshipInformation: RelationshipInformation + relationshipInformationList: RelationshipInformation[] ) => { const link = new RelationshipLink({ source: { id: sourceId }, target: { id: targetId }, - relationshipInformation + relationshipInformationList, + // Keep the first one for backward compatibility + relationshipInformation: relationshipInformationList[0] }); // Set markers based on relationship direction diff --git a/Website/components/diagramview/smaller-components/RelationshipProperties.tsx b/Website/components/diagramview/smaller-components/RelationshipProperties.tsx index e4fbf29..1a3a741 100644 --- a/Website/components/diagramview/smaller-components/RelationshipProperties.tsx +++ b/Website/components/diagramview/smaller-components/RelationshipProperties.tsx @@ -1,5 +1,5 @@ import { RelationshipInformation } from '@/lib/diagram/models/relationship-information'; -import { Box, Paper, Typography } from '@mui/material'; +import { Box, Chip, Paper, Typography } from '@mui/material'; import React from 'react' interface IRelationshipPropertiesProps { @@ -8,21 +8,57 @@ interface IRelationshipPropertiesProps { const RelationshipProperties = ({ relationships }: IRelationshipPropertiesProps) => { return ( - - Relationship Properties - {relationships.map((rel, index) => ( - - - {rel.sourceEntityDisplayName} - {rel.sourceEntitySchemaName} + + + Relationship Properties + + + {relationships.length} relationship{relationships.length !== 1 ? 's' : ''} + + + {relationships.map((rel, index) => ( + + + + {rel.sourceEntityDisplayName} + {rel.sourceEntitySchemaName} + + {rel.RelationshipType} + + {rel.targetEntityDisplayName} + {rel.targetEntitySchemaName} + - {rel.RelationshipType} - - {rel.targetEntityDisplayName} - {rel.targetEntitySchemaName} + + {/* Additional relationship details */} + + {rel.RelationshipName && ( + + Name: {rel.RelationshipName} + + )} + {rel.RelationshipSchemaName && ( + + Schema: {rel.RelationshipSchemaName} + + )} + {rel.LookupAttributeName && ( + + Lookup Attribute: {rel.LookupAttributeName} + + )} + + {rel.IsLookup && ( + + )} + {rel.IsManyToMany && ( + + )} + ))} + ) } diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 5cf418c..16f33e0 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -503,23 +503,132 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }); }; - // Decide if two entities should be linked (including self-referencing) - const shouldLinkEntities = (a: EntityType, b: EntityType): boolean => { - if (!a || !b) return false; - - // Allow self-referencing relationships - if (a.SchemaName === b.SchemaName) { - // Check if entity has self-referencing lookup or relationship - const hasSelfLookup = hasLookupTo(a, a.SchemaName); - const hasSelfRelationship = hasRelationshipTo(a, a.SchemaName); - return hasSelfLookup || hasSelfRelationship; + // Collect ALL relationships between two entities + // All relationships are normalized to be relative to sourceEntity -> targetEntity direction + const getAllRelationshipsBetween = (sourceEntity: EntityType, targetEntity: EntityType): RelationshipInformation[] => { + if (!sourceEntity || !targetEntity) return []; + + const relationships: RelationshipInformation[] = []; + const isSelfReferencing = sourceEntity.SchemaName === targetEntity.SchemaName; + + // Collect lookup-based relationships from source to target + // These are: source (many) -> target (one) + if (sourceEntity.Attributes) { + sourceEntity.Attributes.forEach(attr => { + if (attr.AttributeType === 'LookupAttribute') { + const lookupAttr = attr as any; + if (lookupAttr.Targets) { + lookupAttr.Targets.forEach((target: ExtendedEntityInformationType) => { + if (target.Name?.toLowerCase() === targetEntity.SchemaName.toLowerCase()) { + relationships.push({ + sourceEntitySchemaName: sourceEntity.SchemaName, + sourceEntityDisplayName: sourceEntity.DisplayName, + targetEntitySchemaName: targetEntity.SchemaName, + targetEntityDisplayName: targetEntity.DisplayName, + RelationshipType: isSelfReferencing ? 'SELF' : 'M-1', + RelationshipSchemaName: '', + RelationshipName: `${attr.DisplayName} (from ${sourceEntity.DisplayName})`, + IsLookup: true, + LookupAttributeName: attr.SchemaName, + IsManyToMany: false + }); + } + }); + } + } + }); } - // Link if either side references the other via lookup or relationship - const aToB = hasLookupTo(a, b.SchemaName) || hasRelationshipTo(a, b.SchemaName); - const bToA = hasLookupTo(b, a.SchemaName) || hasRelationshipTo(b, a.SchemaName); + // Collect explicit relationships from source entity + if (sourceEntity.Relationships) { + sourceEntity.Relationships.forEach(rel => { + if (rel.TableSchema?.toLowerCase() === targetEntity.SchemaName.toLowerCase()) { + const direction = rel.IsManyToMany ? 'M-M' : (isSelfReferencing ? 'SELF' : '1-M'); + relationships.push({ + sourceEntitySchemaName: sourceEntity.SchemaName, + sourceEntityDisplayName: sourceEntity.DisplayName, + targetEntitySchemaName: targetEntity.SchemaName, + targetEntityDisplayName: targetEntity.DisplayName, + RelationshipType: direction, + RelationshipSchemaName: rel.RelationshipSchema, + RelationshipName: `${rel.LookupDisplayName || rel.Name} (from ${sourceEntity.DisplayName})`, + IsLookup: false, + IsManyToMany: rel.IsManyToMany + }); + } + }); + } - return aToB || bToA; + // If not self-referencing, also check relationships from target to source + // These need to be normalized to show from source's perspective + if (!isSelfReferencing) { + // Collect lookup-based relationships from target to source + // These are: source (one) <- target (many), normalized to: source (1) -> target (M) = 1-M + if (targetEntity.Attributes) { + targetEntity.Attributes.forEach(attr => { + if (attr.AttributeType === 'LookupAttribute') { + const lookupAttr = attr as any; + if (lookupAttr.Targets) { + lookupAttr.Targets.forEach((target: ExtendedEntityInformationType) => { + if (target.Name?.toLowerCase() === sourceEntity.SchemaName.toLowerCase()) { + // Normalize to source -> target perspective + // Target has lookup to source, meaning source is "one", target is "many" + // From source perspective: source (1) <- target (M) = 1-M relationship + relationships.push({ + sourceEntitySchemaName: sourceEntity.SchemaName, + sourceEntityDisplayName: sourceEntity.DisplayName, + targetEntitySchemaName: targetEntity.SchemaName, + targetEntityDisplayName: targetEntity.DisplayName, + RelationshipType: '1-M', + RelationshipSchemaName: '', + RelationshipName: `${attr.DisplayName} (from ${targetEntity.DisplayName})`, + IsLookup: true, + LookupAttributeName: attr.SchemaName, + IsManyToMany: false + }); + } + }); + } + } + }); + } + + // Collect explicit relationships from target entity + if (targetEntity.Relationships) { + targetEntity.Relationships.forEach(rel => { + if (rel.TableSchema?.toLowerCase() === sourceEntity.SchemaName.toLowerCase()) { + // Don't duplicate M-M relationships (they would be in both entities) + const isDuplicate = rel.IsManyToMany && relationships.some(r => + r.IsManyToMany && r.RelationshipSchemaName === rel.RelationshipSchema + ); + + if (!isDuplicate) { + // Normalize to source -> target perspective + const direction = rel.IsManyToMany ? 'M-M' : 'M-1'; + relationships.push({ + sourceEntitySchemaName: sourceEntity.SchemaName, + sourceEntityDisplayName: sourceEntity.DisplayName, + targetEntitySchemaName: targetEntity.SchemaName, + targetEntityDisplayName: targetEntity.DisplayName, + RelationshipType: direction, + RelationshipSchemaName: rel.RelationshipSchema, + RelationshipName: `${rel.LookupDisplayName || rel.Name} (from ${targetEntity.DisplayName})`, + IsLookup: false, + IsManyToMany: rel.IsManyToMany + }); + } + } + }); + } + } + + return relationships; + }; + + // Decide if two entities should be linked (including self-referencing) + const shouldLinkEntities = (a: EntityType, b: EntityType): boolean => { + const relationships = getAllRelationshipsBetween(a, b); + return relationships.length > 0; }; // Do we already have a link between two element ids (including self-referencing)? @@ -545,27 +654,21 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { const createDirectedLink = (graph: dia.Graph, sourceEl: dia.Element, targetEl: dia.Element) => { const sourceData = sourceEl.get('entityData') as EntityType; const targetData = targetEl.get('entityData') as EntityType; - + if (!sourceData || !targetData) return; - const direction = getRelationshipDirection(sourceData, targetData); - - if (direction) { - const info: RelationshipInformation = { - sourceEntitySchemaName: sourceData.SchemaName, - sourceEntityDisplayName: sourceData.DisplayName, - targetEntitySchemaName: targetData.SchemaName, - targetEntityDisplayName: targetData.DisplayName, - RelationshipType: direction, - RelationshipSchemaName: "" // TODO: found inside the relationship definitions (but what about lookups?) - }; - const link = createDirectedRelationshipLink(sourceEl.id, targetEl.id, direction, info); - graph.addCell(link); - } else { - // Fallback to undirected link if direction cannot be determined - const link = createRelationshipLink(sourceEl.id, targetEl.id); - graph.addCell(link); - } + // Get all relationships between the two entities + const allRelationships = getAllRelationshipsBetween(sourceData, targetData); + + // Determine the primary direction based on the first/strongest relationship + // Priority: M-M > SELF > M-1 > 1-M + const direction = allRelationships.find(r => r.RelationshipType === 'M-M')?.RelationshipType + || allRelationships.find(r => r.RelationshipType === 'SELF')?.RelationshipType + || allRelationships.find(r => r.RelationshipType === 'M-1')?.RelationshipType + || allRelationships[0].RelationshipType; + + const link = createDirectedRelationshipLink(sourceEl.id, targetEl.id, direction, allRelationships); + graph.addCell(link); }; // Find + add links between a *new* entity element and all existing ones (including self-referencing) diff --git a/Website/lib/diagram/DiagramEventBridge.ts b/Website/lib/diagram/DiagramEventBridge.ts index c5ad648..ac1f336 100644 --- a/Website/lib/diagram/DiagramEventBridge.ts +++ b/Website/lib/diagram/DiagramEventBridge.ts @@ -1,5 +1,5 @@ import { SelectObjectEvent } from '@/components/diagramview/events/SelectObjectEvent'; -import { EntityType, RelationshipType } from '../Types'; +import { EntityType } from '../Types'; import { RelationshipInformation } from './models/relationship-information'; /** @@ -29,12 +29,15 @@ export class DiagramEventBridge { window.dispatchEvent(event); } - dispatchRelationshipSelect(relationshipId: string, relationshipData: RelationshipInformation) { + dispatchRelationshipSelect(relationshipId: string, relationshipData: RelationshipInformation | RelationshipInformation[]) { + // Ensure data is always an array + const dataArray = Array.isArray(relationshipData) ? relationshipData : [relationshipData]; + const event = new CustomEvent('selectObject', { detail: { type: 'relationship', objectId: relationshipId, - data: [relationshipData] + data: dataArray } }); window.dispatchEvent(event); diff --git a/Website/lib/diagram/models/relationship-information.ts b/Website/lib/diagram/models/relationship-information.ts index a88055e..8f487ee 100644 --- a/Website/lib/diagram/models/relationship-information.ts +++ b/Website/lib/diagram/models/relationship-information.ts @@ -5,4 +5,9 @@ export type RelationshipInformation = { targetEntityDisplayName: string, RelationshipType: '1-M' | 'M-1' | 'M-M' | 'SELF', RelationshipSchemaName: string, + // Additional details about the relationship + RelationshipName?: string, // Display name or lookup name + IsManyToMany?: boolean, + IsLookup?: boolean, // True if this is a lookup attribute relationship + LookupAttributeName?: string, // Name of the lookup attribute if IsLookup is true } \ No newline at end of file diff --git a/Website/lib/diagram/models/serialized-entity.ts b/Website/lib/diagram/models/serialized-entity.ts index 592b2f5..43a3a8c 100644 --- a/Website/lib/diagram/models/serialized-entity.ts +++ b/Website/lib/diagram/models/serialized-entity.ts @@ -4,4 +4,6 @@ export interface SerializedEntity { position: { x: number; y: number }; size: { width: number; height: number }; label: string; + includedRelationships: string[]; + excludedRelationships: string[]; } \ No newline at end of file From bee13242f0c6d46e39e583250c1e4489e48b0e44 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sun, 26 Oct 2025 13:07:57 +0100 Subject: [PATCH 41/51] chore: refactor relationship logic to seperate file --- .../diagram-elements/RelationshipLink.ts | 21 +- Website/contexts/DiagramViewContext.tsx | 283 +----------------- Website/lib/diagram/relationship-helpers.ts | 96 ++++++ 3 files changed, 114 insertions(+), 286 deletions(-) create mode 100644 Website/lib/diagram/relationship-helpers.ts diff --git a/Website/components/diagramview/diagram-elements/RelationshipLink.ts b/Website/components/diagramview/diagram-elements/RelationshipLink.ts index c75011e..b282232 100644 --- a/Website/components/diagramview/diagram-elements/RelationshipLink.ts +++ b/Website/components/diagramview/diagram-elements/RelationshipLink.ts @@ -91,7 +91,6 @@ const circleMarker = { export const createDirectedRelationshipLink = ( sourceId: dia.Cell.ID, targetId: dia.Cell.ID, - direction: '1-M' | 'M-1' | 'M-M' | 'SELF', relationshipInformationList: RelationshipInformation[] ) => { const link = new RelationshipLink({ @@ -102,24 +101,16 @@ export const createDirectedRelationshipLink = ( relationshipInformation: relationshipInformationList[0] }); - // Set markers based on relationship direction - switch (direction) { - case '1-M': + relationshipInformationList.forEach((relInfo) => { + if (relInfo.RelationshipType === '1-M') { link.attr('line/targetMarker', circleMarker); - break; - case 'M-1': - link.attr('line/sourceMarker', circleMarker); - break; - case 'M-M': + } else if (relInfo.RelationshipType === 'M-1' || relInfo.RelationshipType === 'SELF') { link.attr('line/sourceMarker', circleMarker); + } else if (relInfo.RelationshipType === 'M-M') { link.attr('line/targetMarker', circleMarker); - break; - case 'SELF': - // Self-referencing relationship - create a loop with markers link.attr('line/sourceMarker', circleMarker); - link.attr('line/targetMarker', circleMarker); - break; - } + } + }); if (sourceId === targetId) { link.set('source', { id: sourceId, port: 'self-out' }); diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 16f33e0..5eda677 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -3,10 +3,11 @@ import React, { createContext, useContext, ReactNode, useReducer, useEffect, use import { createEntity, EntityElement, EntityElementView } from '@/components/diagramview/diagram-elements/EntityElement'; import EntitySelection, { SelectionElement } from '@/components/diagramview/diagram-elements/Selection'; import { SmartLayout } from '@/components/diagramview/layout/SmartLayout'; -import { EntityType, ExtendedEntityInformationType } from '@/lib/Types'; +import { EntityType } from '@/lib/Types'; import { AvoidRouter } from '@/components/diagramview/avoid-router/shared/avoidrouter'; import { initializeRouter } from '@/components/diagramview/avoid-router/shared/initialization'; -import { createRelationshipLink, createDirectedRelationshipLink, RelationshipLink, RelationshipLinkView } from '@/components/diagramview/diagram-elements/RelationshipLink'; +import { createDirectedRelationshipLink, RelationshipLink, RelationshipLinkView } from '@/components/diagramview/diagram-elements/RelationshipLink'; +import { getAllRelationshipsBetween, linkExistsBetween } from '@/lib/diagram/relationship-helpers'; import { RelationshipInformation } from '@/lib/diagram/models/relationship-information'; interface DiagramActions { @@ -398,276 +399,14 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { }; }, []); - // Determine the relationship direction between two entities - const getRelationshipDirection = (sourceEntity: EntityType, targetEntity: EntityType): '1-M' | 'M-1' | 'M-M' | 'SELF' | null => { - if (!sourceEntity || !targetEntity) return null; - - // Handle self-referencing relationships - if (sourceEntity.SchemaName === targetEntity.SchemaName) { - // Check if entity has self-referencing lookup or relationship - const hasSelfLookup = hasLookupTo(sourceEntity, sourceEntity.SchemaName); - const hasSelfRelationship = sourceEntity.Relationships?.some(r => - r.TableSchema?.toLowerCase() === sourceEntity.SchemaName.toLowerCase() - ); - - if (hasSelfLookup || hasSelfRelationship) { - return 'SELF'; - } - return null; - } - - let sourceToTargetType: 'none' | '1' | 'M' = 'none'; - let targetToSourceType: 'none' | '1' | 'M' = 'none'; - - // Check if source has a lookup to target (source is "many", target is "one") - if (hasLookupTo(sourceEntity, targetEntity.SchemaName)) { - sourceToTargetType = 'M'; - targetToSourceType = '1'; - } - - // Check if target has a lookup to source (target is "many", source is "one") - if (hasLookupTo(targetEntity, sourceEntity.SchemaName)) { - targetToSourceType = 'M'; - sourceToTargetType = '1'; - } - - // Check relationships from source entity - if (sourceEntity.Relationships) { - const sourceRelationship = sourceEntity.Relationships.find(r => - r.TableSchema?.toLowerCase() === targetEntity.SchemaName.toLowerCase() - ); - - if (sourceRelationship) { - if (sourceRelationship.IsManyToMany) { - return 'M-M'; - } - // For 1-to-many relationships defined in the source entity, - // the source is typically the "1" side and target is the "many" side - if (sourceToTargetType === 'none') { - sourceToTargetType = '1'; - targetToSourceType = 'M'; - } - } - } - - // Check relationships from target entity - if (targetEntity.Relationships) { - const targetRelationship = targetEntity.Relationships.find(r => - r.TableSchema?.toLowerCase() === sourceEntity.SchemaName.toLowerCase() - ); - - if (targetRelationship) { - if (targetRelationship.IsManyToMany) { - return 'M-M'; - } - // For 1-to-many relationships defined in the target entity, - // the target is typically the "1" side and source is the "many" side - if (targetToSourceType === 'none') { - targetToSourceType = '1'; - sourceToTargetType = 'M'; - } - } - } - - // Determine final direction - if (sourceToTargetType === '1' && targetToSourceType === 'M') { - return '1-M'; // Source is one, target is many - } else if (sourceToTargetType === 'M' && targetToSourceType === '1') { - return 'M-1'; // Source is many, target is one - } else if (sourceToTargetType === 'M' && targetToSourceType === 'M') { - return 'M-M'; // Both are many - } - - return null; // Unable to determine or no relationship - }; - - // True if an entity has a lookup attribute targeting targetSchema - const hasLookupTo = (entity: EntityType, targetSchema: string): boolean => { - return entity.Attributes?.some(a => a.AttributeType === "LookupAttribute" && - (a as any).Targets?.some((t: ExtendedEntityInformationType) => - t?.Name?.toLowerCase() === targetSchema.toLowerCase() - ) - ) ?? false; - }; - - // True if an entity declares a relationship to targetSchema - const hasRelationshipTo = (entity: EntityType, targetSchema: string): boolean => { - if (!entity.Relationships) return false; - - const needle = targetSchema.toLowerCase(); - return entity.Relationships.some(r => { - const tableHit = r.TableSchema?.toLowerCase() === needle; - const nameHit = r.Name?.toLowerCase() === needle; - const schemaHit = r.RelationshipSchema?.toLowerCase()?.includes(needle); - return tableHit || nameHit || schemaHit; - }); - }; - - // Collect ALL relationships between two entities - // All relationships are normalized to be relative to sourceEntity -> targetEntity direction - const getAllRelationshipsBetween = (sourceEntity: EntityType, targetEntity: EntityType): RelationshipInformation[] => { - if (!sourceEntity || !targetEntity) return []; - - const relationships: RelationshipInformation[] = []; - const isSelfReferencing = sourceEntity.SchemaName === targetEntity.SchemaName; - - // Collect lookup-based relationships from source to target - // These are: source (many) -> target (one) - if (sourceEntity.Attributes) { - sourceEntity.Attributes.forEach(attr => { - if (attr.AttributeType === 'LookupAttribute') { - const lookupAttr = attr as any; - if (lookupAttr.Targets) { - lookupAttr.Targets.forEach((target: ExtendedEntityInformationType) => { - if (target.Name?.toLowerCase() === targetEntity.SchemaName.toLowerCase()) { - relationships.push({ - sourceEntitySchemaName: sourceEntity.SchemaName, - sourceEntityDisplayName: sourceEntity.DisplayName, - targetEntitySchemaName: targetEntity.SchemaName, - targetEntityDisplayName: targetEntity.DisplayName, - RelationshipType: isSelfReferencing ? 'SELF' : 'M-1', - RelationshipSchemaName: '', - RelationshipName: `${attr.DisplayName} (from ${sourceEntity.DisplayName})`, - IsLookup: true, - LookupAttributeName: attr.SchemaName, - IsManyToMany: false - }); - } - }); - } - } - }); - } - - // Collect explicit relationships from source entity - if (sourceEntity.Relationships) { - sourceEntity.Relationships.forEach(rel => { - if (rel.TableSchema?.toLowerCase() === targetEntity.SchemaName.toLowerCase()) { - const direction = rel.IsManyToMany ? 'M-M' : (isSelfReferencing ? 'SELF' : '1-M'); - relationships.push({ - sourceEntitySchemaName: sourceEntity.SchemaName, - sourceEntityDisplayName: sourceEntity.DisplayName, - targetEntitySchemaName: targetEntity.SchemaName, - targetEntityDisplayName: targetEntity.DisplayName, - RelationshipType: direction, - RelationshipSchemaName: rel.RelationshipSchema, - RelationshipName: `${rel.LookupDisplayName || rel.Name} (from ${sourceEntity.DisplayName})`, - IsLookup: false, - IsManyToMany: rel.IsManyToMany - }); - } - }); - } - - // If not self-referencing, also check relationships from target to source - // These need to be normalized to show from source's perspective - if (!isSelfReferencing) { - // Collect lookup-based relationships from target to source - // These are: source (one) <- target (many), normalized to: source (1) -> target (M) = 1-M - if (targetEntity.Attributes) { - targetEntity.Attributes.forEach(attr => { - if (attr.AttributeType === 'LookupAttribute') { - const lookupAttr = attr as any; - if (lookupAttr.Targets) { - lookupAttr.Targets.forEach((target: ExtendedEntityInformationType) => { - if (target.Name?.toLowerCase() === sourceEntity.SchemaName.toLowerCase()) { - // Normalize to source -> target perspective - // Target has lookup to source, meaning source is "one", target is "many" - // From source perspective: source (1) <- target (M) = 1-M relationship - relationships.push({ - sourceEntitySchemaName: sourceEntity.SchemaName, - sourceEntityDisplayName: sourceEntity.DisplayName, - targetEntitySchemaName: targetEntity.SchemaName, - targetEntityDisplayName: targetEntity.DisplayName, - RelationshipType: '1-M', - RelationshipSchemaName: '', - RelationshipName: `${attr.DisplayName} (from ${targetEntity.DisplayName})`, - IsLookup: true, - LookupAttributeName: attr.SchemaName, - IsManyToMany: false - }); - } - }); - } - } - }); - } - - // Collect explicit relationships from target entity - if (targetEntity.Relationships) { - targetEntity.Relationships.forEach(rel => { - if (rel.TableSchema?.toLowerCase() === sourceEntity.SchemaName.toLowerCase()) { - // Don't duplicate M-M relationships (they would be in both entities) - const isDuplicate = rel.IsManyToMany && relationships.some(r => - r.IsManyToMany && r.RelationshipSchemaName === rel.RelationshipSchema - ); - - if (!isDuplicate) { - // Normalize to source -> target perspective - const direction = rel.IsManyToMany ? 'M-M' : 'M-1'; - relationships.push({ - sourceEntitySchemaName: sourceEntity.SchemaName, - sourceEntityDisplayName: sourceEntity.DisplayName, - targetEntitySchemaName: targetEntity.SchemaName, - targetEntityDisplayName: targetEntity.DisplayName, - RelationshipType: direction, - RelationshipSchemaName: rel.RelationshipSchema, - RelationshipName: `${rel.LookupDisplayName || rel.Name} (from ${targetEntity.DisplayName})`, - IsLookup: false, - IsManyToMany: rel.IsManyToMany - }); - } - } - }); - } - } - - return relationships; - }; - - // Decide if two entities should be linked (including self-referencing) - const shouldLinkEntities = (a: EntityType, b: EntityType): boolean => { - const relationships = getAllRelationshipsBetween(a, b); - return relationships.length > 0; - }; - - // Do we already have a link between two element ids (including self-referencing)? - const linkExistsBetween = (graph: dia.Graph, aId: string, bId: string): boolean => { - const links = graph.getLinks(); - return links.some(l => { - const s = l.get('source'); - const t = l.get('target'); - const sId = typeof s?.id === 'string' ? s.id : s?.id?.toString?.(); - const tId = typeof t?.id === 'string' ? t.id : t?.id?.toString?.(); - - // Handle self-referencing links (same source and target) - if (aId === bId) { - return sId === aId && tId === bId; - } - - // Handle regular links (bidirectional check) - return (sId === aId && tId === bId) || (sId === bId && tId === aId); - }); - }; - // Create a directed link between two entity elements based on their relationship - const createDirectedLink = (graph: dia.Graph, sourceEl: dia.Element, targetEl: dia.Element) => { + const createDirectedLink = (graph: dia.Graph, sourceEl: dia.Element, targetEl: dia.Element, allRelationships: RelationshipInformation[]) => { const sourceData = sourceEl.get('entityData') as EntityType; const targetData = targetEl.get('entityData') as EntityType; if (!sourceData || !targetData) return; - // Get all relationships between the two entities - const allRelationships = getAllRelationshipsBetween(sourceData, targetData); - - // Determine the primary direction based on the first/strongest relationship - // Priority: M-M > SELF > M-1 > 1-M - const direction = allRelationships.find(r => r.RelationshipType === 'M-M')?.RelationshipType - || allRelationships.find(r => r.RelationshipType === 'SELF')?.RelationshipType - || allRelationships.find(r => r.RelationshipType === 'M-1')?.RelationshipType - || allRelationships[0].RelationshipType; - - const link = createDirectedRelationshipLink(sourceEl.id, targetEl.id, direction, allRelationships); + const link = createDirectedRelationshipLink(sourceEl.id, targetEl.id, allRelationships); graph.addCell(link); }; @@ -675,13 +414,14 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { const linkNewEntityToExisting = (graph: dia.Graph, newEl: dia.Element) => { const newData = newEl.get('entityData') as EntityType; if (!newData) return; + + const selfReferencingRelationships = getAllRelationshipsBetween(newData, newData); // Check for self-referencing relationship first - if (shouldLinkEntities(newData, newData)) { - console.log("Self-referencing relationship detected"); + if (selfReferencingRelationships.length > 0) { // Entity has self-referencing relationship if (!linkExistsBetween(graph, newEl.id.toString(), newEl.id.toString())) { - createDirectedLink(graph, newEl, newEl); + createDirectedLink(graph, newEl, newEl, selfReferencingRelationships); } } @@ -694,9 +434,10 @@ export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { const otherData = el.get('entityData') as EntityType; if (!otherData) continue; - if (shouldLinkEntities(newData, otherData)) { + const relationships = getAllRelationshipsBetween(newData, otherData); + if (relationships.length > 0) { if (!linkExistsBetween(graph, newEl.id.toString(), el.id.toString())) { - createDirectedLink(graph, newEl, el); + createDirectedLink(graph, newEl, el, relationships); } } } diff --git a/Website/lib/diagram/relationship-helpers.ts b/Website/lib/diagram/relationship-helpers.ts new file mode 100644 index 0000000..ce95db8 --- /dev/null +++ b/Website/lib/diagram/relationship-helpers.ts @@ -0,0 +1,96 @@ +import { EntityType, ExtendedEntityInformationType } from '../Types'; +import { RelationshipInformation } from './models/relationship-information'; +import { dia } from '@joint/core'; + +/** + * Collect ALL relationships between two entities + * Only uses the Relationships array - ignores lookup attributes as they're redundant + * All relationships are normalized to be relative to sourceEntity -> targetEntity direction + */ +export const getAllRelationshipsBetween = (sourceEntity: EntityType, targetEntity: EntityType): RelationshipInformation[] => { + if (!sourceEntity || !targetEntity) return []; + + const relationships: RelationshipInformation[] = []; + const isSelfReferencing = sourceEntity.SchemaName === targetEntity.SchemaName; + const seenSchemas = new Set(); // Track relationship schemas to avoid duplicates + + // Helper to add relationship if not duplicate + const addRelationship = (rel: RelationshipInformation) => { + // For M-M relationships, use schema to detect duplicates + if (rel.IsManyToMany && rel.RelationshipSchemaName) { + if (seenSchemas.has(rel.RelationshipSchemaName)) { + return; // Skip duplicate M-M relationship + } + seenSchemas.add(rel.RelationshipSchemaName); + } + relationships.push(rel); + }; + + // Collect relationships from SOURCE entity pointing to TARGET + // These are 1-M relationships where source is the "1" (parent) side + if (sourceEntity.Relationships) { + sourceEntity.Relationships.forEach(rel => { + if (rel.TableSchema?.toLowerCase() === targetEntity.SchemaName.toLowerCase()) { + const direction = rel.IsManyToMany ? 'M-M' : (isSelfReferencing ? 'SELF' : '1-M'); + addRelationship({ + sourceEntitySchemaName: sourceEntity.SchemaName, + sourceEntityDisplayName: sourceEntity.DisplayName, + targetEntitySchemaName: targetEntity.SchemaName, + targetEntityDisplayName: targetEntity.DisplayName, + RelationshipType: direction, + RelationshipSchemaName: rel.RelationshipSchema, + RelationshipName: `${rel.LookupDisplayName || rel.Name} (from ${sourceEntity.DisplayName})`, + IsLookup: false, + IsManyToMany: rel.IsManyToMany + }); + } + }); + } + + // If not self-referencing, collect relationships from TARGET entity pointing to SOURCE + // These represent M-1 relationships from source's perspective (target is "1", source is "many") + if (!isSelfReferencing && targetEntity.Relationships) { + targetEntity.Relationships.forEach(rel => { + if (rel.TableSchema?.toLowerCase() === sourceEntity.SchemaName.toLowerCase()) { + // Normalize to source -> target perspective + // Target pointing to source means: target (many) -> source (one) + // From source perspective: source (one) <- target (many) = M-1 + const direction = rel.IsManyToMany ? 'M-M' : 'M-1'; + addRelationship({ + sourceEntitySchemaName: sourceEntity.SchemaName, + sourceEntityDisplayName: sourceEntity.DisplayName, + targetEntitySchemaName: targetEntity.SchemaName, + targetEntityDisplayName: targetEntity.DisplayName, + RelationshipType: direction, + RelationshipSchemaName: rel.RelationshipSchema, + RelationshipName: `${rel.LookupDisplayName || rel.Name} (from ${targetEntity.DisplayName})`, + IsLookup: false, + IsManyToMany: rel.IsManyToMany + }); + } + }); + } + + return relationships; +}; + +/** + * Check if a link already exists between two element ids (including self-referencing) + */ +export const linkExistsBetween = (graph: dia.Graph, aId: string, bId: string): boolean => { + const links = graph.getLinks(); + return links.some(l => { + const s = l.get('source'); + const t = l.get('target'); + const sId = typeof s?.id === 'string' ? s.id : s?.id?.toString?.(); + const tId = typeof t?.id === 'string' ? t.id : t?.id?.toString?.(); + + // Handle self-referencing links (same source and target) + if (aId === bId) { + return sId === aId && tId === bId; + } + + // Handle regular links (bidirectional check) + return (sId === aId && tId === bId) || (sId === bId && tId === aId); + }); +}; From 0a44fb2d2fa32cb1768c9abe30113048f95f33bc Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sun, 26 Oct 2025 15:50:46 +0100 Subject: [PATCH 42/51] feat: control the exclude/include of relationships between entities. See new relations to loaded diagrams. --- .../datamodelview/dataLoaderWorker.js | 5 - .../datamodelview/dataLoaderWorker.ts | 12 + .../diagramview/PropertiesPanel.tsx | 30 +- .../diagram-elements/EntityElement.ts | 58 +-- .../diagram-elements/RelationshipLink.ts | 89 +++-- .../diagramview/events/SelectObjectEvent.ts | 2 +- .../smaller-components/EntityProperties.tsx | 11 +- .../RelationshipProperties.tsx | 114 +++--- Website/contexts/DatamodelDataContext.tsx | 32 +- Website/contexts/DiagramViewContext.tsx | 337 ++++++++++-------- Website/lib/diagram/DiagramEventBridge.ts | 18 +- .../models/relationship-information.ts | 7 +- .../lib/diagram/models/serialized-diagram.ts | 2 + .../lib/diagram/models/serialized-entity.ts | 3 +- Website/lib/diagram/models/serialized-link.ts | 13 + Website/lib/diagram/relationship-helpers.ts | 11 +- .../services/diagram-deserialization.ts | 51 ++- .../diagram/services/diagram-serialization.ts | 48 ++- 18 files changed, 513 insertions(+), 330 deletions(-) delete mode 100644 Website/components/datamodelview/dataLoaderWorker.js create mode 100644 Website/components/datamodelview/dataLoaderWorker.ts create mode 100644 Website/lib/diagram/models/serialized-link.ts diff --git a/Website/components/datamodelview/dataLoaderWorker.js b/Website/components/datamodelview/dataLoaderWorker.js deleted file mode 100644 index dac13fe..0000000 --- a/Website/components/datamodelview/dataLoaderWorker.js +++ /dev/null @@ -1,5 +0,0 @@ -import { Groups, SolutionWarnings, Solutions } from '../../generated/Data'; - -self.onmessage = function() { - self.postMessage({ groups: Groups, warnings: SolutionWarnings, solutions: Solutions }); -}; \ No newline at end of file diff --git a/Website/components/datamodelview/dataLoaderWorker.ts b/Website/components/datamodelview/dataLoaderWorker.ts new file mode 100644 index 0000000..a2cdbf8 --- /dev/null +++ b/Website/components/datamodelview/dataLoaderWorker.ts @@ -0,0 +1,12 @@ +import { EntityType } from '@/lib/Types'; +import { Groups, SolutionWarnings, Solutions } from '../../generated/Data'; + +self.onmessage = function () { + const entityMap = new Map(); + Groups.forEach(group => { + group.Entities.forEach(entity => { + entityMap.set(entity.SchemaName, entity); + }); + }); + self.postMessage({ groups: Groups, entityMap: entityMap, warnings: SolutionWarnings, solutions: Solutions }); +}; \ No newline at end of file diff --git a/Website/components/diagramview/PropertiesPanel.tsx b/Website/components/diagramview/PropertiesPanel.tsx index d0cd5de..f9f1a77 100644 --- a/Website/components/diagramview/PropertiesPanel.tsx +++ b/Website/components/diagramview/PropertiesPanel.tsx @@ -16,8 +16,10 @@ interface IPropertiesPanelProps { export default function PropertiesPanel({ }: IPropertiesPanelProps) { const [object, setObject] = useState(null); const objectRef = useRef(null); - const userClosedRef = useRef(false); + const [isForcedClosed, setIsForcedClosed] = useState(false); + const userClosedRef = useRef(false); + const [isOpen, setIsOpen] = useState(false); const togglePane = () => { @@ -55,7 +57,7 @@ export default function PropertiesPanel({ }: IPropertiesPanelProps) { case 'entity': return ; case 'relationship': - return ; + return ; case 'selection': return ; } @@ -63,20 +65,20 @@ export default function PropertiesPanel({ }: IPropertiesPanelProps) { return ( - {isOpen ? : } diff --git a/Website/components/diagramview/diagram-elements/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts index 956a5d7..752dff5 100644 --- a/Website/components/diagramview/diagram-elements/EntityElement.ts +++ b/Website/components/diagramview/diagram-elements/EntityElement.ts @@ -3,7 +3,7 @@ import { EntityType } from '@/lib/Types'; import { diagramEvents } from '@/lib/diagram/DiagramEventBridge'; export type EntityElement = dia.Element & { - get(key: 'entityData'): EntityType | undefined; + get(key: 'entityData'): EntityType; get(key: 'label'): string | undefined; }; export type EntityElementView = dia.ElementView & { @@ -19,7 +19,7 @@ interface IEntityOptions extends mvc.ViewBaseOptions { }; export const EntityElementView = dia.ElementView.extend({ - + events: { 'mouseenter': 'onMouseEnter', 'mouseleave': 'onMouseLeave', @@ -28,13 +28,13 @@ export const EntityElementView = dia.ElementView.extend({ 'pointerup': 'onPointerUp', }, - initialize: function(options?: IEntityOptions) { + initialize: function (options?: IEntityOptions) { dia.ElementView.prototype.initialize.call(this, options); this.updateTitle(); this.isSelected = false; // Track selection state }, - onMouseEnter: function() { + onMouseEnter: function () { // Only apply hover effects if not selected if (!this.isSelected) { this.model.attr('container/style/cursor', 'move'); @@ -43,7 +43,7 @@ export const EntityElementView = dia.ElementView.extend({ } }, - onMouseLeave: function() { + onMouseLeave: function () { // Only remove hover effects if not selected if (!this.isSelected) { this.model.attr('container/style/cursor', 'default'); @@ -52,57 +52,57 @@ export const EntityElementView = dia.ElementView.extend({ } }, - onContextMenu: function(evt: MouseEvent) { + onContextMenu: function (evt: MouseEvent) { evt.preventDefault(); evt.stopPropagation(); // Dispatch a custom event for context menu diagramEvents.dispatchEntityContextMenu( - String(this.model.id), - evt.clientX, + String(this.model.id), + evt.clientX, evt.clientY ); }, - onPointerDown: function() { + onPointerDown: function () { this.model.attr('container/style/cursor', 'grabbing'); - + diagramEvents.dispatchEntitySelect( String(this.model.id), this.model.get('entityData') ); }, - onPointerUp: function() { + onPointerUp: function () { this.model.attr('container/style/cursor', 'move'); }, - onSelect: function() { + onSelect: function () { // Apply the same styling as hover but for selection this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-default)'); this.model.attr('container/style/borderColor', 'var(--mui-palette-primary-main)'); this.model.attr('container/style/cursor', 'move'); - + // Mark as selected for state tracking this.isSelected = true; }, - onDeselect: function() { + onDeselect: function () { // Remove selection styling back to normal state this.model.attr('container/style/backgroundColor', 'var(--mui-palette-background-paper)'); this.model.attr('container/style/borderColor', 'var(--mui-palette-border-main)'); this.model.attr('container/style/cursor', 'default'); - + // Mark as not selected this.isSelected = false; }, - updateTitle: function() { + updateTitle: function () { const label = this.model.get('label') || 'Entity'; this.model.attr('title/html', label); }, - remove: function() { + remove: function () { // Clean up any remaining event listeners if (this.dragMoveHandler) { document.removeEventListener('mousemove', this.dragMoveHandler); @@ -151,15 +151,15 @@ export const EntityElement = dia.Element.define('diagram.EntityElement', { ports: { groups: { top: { - position: { name: 'top' }, + position: { name: 'top' }, label: { position: 'outside' }, markup: [{ tagName: 'circle', selector: 'portBody', attributes: { r: 4 } }], attrs: { - portBody: { - magnet: 'true', - stroke: '#888', - fill: '#fff' - } + portBody: { + magnet: 'true', + stroke: '#888', + fill: '#fff' + } } }, right: { @@ -167,17 +167,17 @@ export const EntityElement = dia.Element.define('diagram.EntityElement', { label: { position: 'outside' }, markup: [{ tagName: 'circle', selector: 'portBody', attributes: { r: 4 } }], attrs: { - portBody: { - magnet: 'true', - stroke: '#888', - fill: '#fff' - } + portBody: { + magnet: 'true', + stroke: '#888', + fill: '#fff' + } } } }, items: [ - { id: 'self-in', group: 'top', attrs: { portBody: { display: 'none' } } }, + { id: 'self-in', group: 'top', attrs: { portBody: { display: 'none' } } }, { id: 'self-out', group: 'right', attrs: { portBody: { display: 'none' } } } ] } diff --git a/Website/components/diagramview/diagram-elements/RelationshipLink.ts b/Website/components/diagramview/diagram-elements/RelationshipLink.ts index b282232..58cf7b7 100644 --- a/Website/components/diagramview/diagram-elements/RelationshipLink.ts +++ b/Website/components/diagramview/diagram-elements/RelationshipLink.ts @@ -2,6 +2,12 @@ import { diagramEvents } from "@/lib/diagram/DiagramEventBridge"; import { RelationshipInformation } from "@/lib/diagram/models/relationship-information"; import { dia } from "@joint/core"; +export type RelationshipLink = dia.Link & { + get(key: 'relationshipInformationList'): RelationshipInformation[]; + get(key: 'sourceSchemaName'): string; + get(key: 'targetSchemaName'): string; +}; + export const RelationshipLink = dia.Link.define('diagram.RelationshipLink', { connector: { name: 'jumpover', args: { type: "arc", radius: 10 } }, z: 1, @@ -39,29 +45,28 @@ export const RelationshipLink = dia.Link.define('diagram.RelationshipLink', { } }); - export const RelationshipLinkView = dia.LinkView.extend({ - + events: { 'pointerdown': 'onPointerDown', 'mouseenter': 'onMouseEnter', 'mouseleave': 'onMouseLeave', }, - onMouseEnter: function() { + onMouseEnter: function () { this.model.attr('line/strokeWidth', 2); }, - onMouseLeave: function() { + onMouseLeave: function () { this.model.attr('line/strokeWidth', 1); }, - onPointerDown: function(evt: PointerEvent) { + onPointerDown: function (evt: PointerEvent) { evt.stopPropagation(); evt.preventDefault(); // Get the relationships array from the model - const relationships = this.model.get('relationshipInformationList') || [this.model.get('relationshipInformation')].filter(Boolean); + const relationships = this.model.get('relationshipInformationList') || []; diagramEvents.dispatchRelationshipSelect( String(this.model.id), @@ -70,52 +75,70 @@ export const RelationshipLinkView = dia.LinkView.extend({ } }); -export const createRelationshipLink = (sourceId: dia.Cell.ID, targetId: dia.Cell.ID) => { - return new RelationshipLink({ - source: { id: sourceId }, - target: { id: targetId } - }); -} - const circleMarker = { - type: 'circle', - r: 3, - cx: 4, - z: 1, - fill: 'var(--mui-palette-background-default)', - stroke: 'var(--mui-palette-primary-main)', - 'stroke-width': 1 + type: 'circle', + r: 3, + cx: 4, + z: 1, + fill: 'var(--mui-palette-background-default)', + stroke: 'var(--mui-palette-primary-main)', + 'stroke-width': 1 +}; + +/** + * Calculate and set markers on a link based on included relationships only + */ +export const updateLinkMarkers = (link: dia.Link) => { + const relationshipInformationList = link.get('relationshipInformationList') as RelationshipInformation[] || []; + + // Filter to only included relationships (default to true if not specified) + const includedRelationships = relationshipInformationList.filter(rel => rel.isIncluded !== false); + + // Clear existing markers first + link.attr('line/targetMarker', null); + link.attr('line/sourceMarker', null); + + // Set markers based on included relationships + includedRelationships.forEach((relInfo) => { + if (relInfo.RelationshipType === '1-M') { + link.attr('line/targetMarker', circleMarker); + } else if (relInfo.RelationshipType === 'M-1' || relInfo.RelationshipType === 'SELF') { + link.attr('line/sourceMarker', circleMarker); + } else if (relInfo.RelationshipType === 'M-M') { + link.attr('line/targetMarker', circleMarker); + link.attr('line/sourceMarker', circleMarker); + } + }); }; // Create a directed relationship link with proper markers -export const createDirectedRelationshipLink = ( +export const createRelationshipLink = ( sourceId: dia.Cell.ID, + sourceSchemaName: string, targetId: dia.Cell.ID, + targetSchemaName: string, relationshipInformationList: RelationshipInformation[] ) => { const link = new RelationshipLink({ source: { id: sourceId }, target: { id: targetId }, + sourceSchemaName, + targetSchemaName, relationshipInformationList, - // Keep the first one for backward compatibility - relationshipInformation: relationshipInformationList[0] }); - relationshipInformationList.forEach((relInfo) => { - if (relInfo.RelationshipType === '1-M') { - link.attr('line/targetMarker', circleMarker); - } else if (relInfo.RelationshipType === 'M-1' || relInfo.RelationshipType === 'SELF') { - link.attr('line/sourceMarker', circleMarker); - } else if (relInfo.RelationshipType === 'M-M') { - link.attr('line/targetMarker', circleMarker); - link.attr('line/sourceMarker', circleMarker); - } - }); + // Calculate markers based on included relationships only + updateLinkMarkers(link); if (sourceId === targetId) { link.set('source', { id: sourceId, port: 'self-out' }); link.set('target', { id: targetId, port: 'self-in' }); } + if (relationshipInformationList.some(rel => rel.isIncluded === undefined)) { + link.attr("line/strokeDasharray", "5 5"); + link.attr("line/stroke", "var(--mui-palette-warning-main)"); + } + return link; } \ No newline at end of file diff --git a/Website/components/diagramview/events/SelectObjectEvent.ts b/Website/components/diagramview/events/SelectObjectEvent.ts index ca65b8e..9bcce60 100644 --- a/Website/components/diagramview/events/SelectObjectEvent.ts +++ b/Website/components/diagramview/events/SelectObjectEvent.ts @@ -3,6 +3,6 @@ import { EntityType } from "@/lib/Types"; export type SelectObjectEvent = { type: 'none' | 'entity' | 'selection' | 'relationship'; - objectId: string | null; + objectId: string | undefined; data?: EntityType[] | RelationshipInformation[]; } \ No newline at end of file diff --git a/Website/components/diagramview/smaller-components/EntityProperties.tsx b/Website/components/diagramview/smaller-components/EntityProperties.tsx index 2290127..c7e82cc 100644 --- a/Website/components/diagramview/smaller-components/EntityProperties.tsx +++ b/Website/components/diagramview/smaller-components/EntityProperties.tsx @@ -21,13 +21,13 @@ export default function EntityProperties({ entity, closePane }: IEntityPropertie ) } - const hasRelatedEntities = entity.Relationships.length > 0 || + const hasRelatedEntities = entity.Relationships.length > 0 || entity.Attributes.some(attr => attr.AttributeType === 'LookupAttribute' && attr.Targets.length > 0); return ( - {entity.IconBase64 ? -
: } {entity?.DisplayName ?? 'Unknown Entity'} - + {/* Related Entities Button */} {hasRelatedEntities && (