diff --git a/Infrastructure/main.bicep b/Infrastructure/main.bicep
index 3063b2b..c3fa9ac 100644
--- a/Infrastructure/main.bicep
+++ b/Infrastructure/main.bicep
@@ -4,6 +4,10 @@ param websitePassword string
@secure()
param sessionSecret string
+param adoOrganizationUrl string = ''
+param adoProjectName string = ''
+param adoRepositoryName string = ''
+
var location = resourceGroup().location
@description('Create an App Service Plan')
@@ -23,6 +27,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
@@ -42,10 +49,24 @@ resource webApp 'Microsoft.Web/sites@2021-02-01' = {
name: 'WEBSITE_NODE_DEFAULT_VERSION'
value: '~20'
}
+ {
+ name: 'ADO_ORGANIZATION_URL'
+ value: adoOrganizationUrl
+ }
+ {
+ name: 'ADO_PROJECT_NAME'
+ value: adoProjectName
+ }
+ {
+ name: 'ADO_REPOSITORY_NAME'
+ value: adoRepositoryName
+ }
]
}
}
}
-@description('Output the web app name')
+@description('Output the web app name and managed identity info')
output webAppName string = webApp.name
+output managedIdentityPrincipalId string = webApp.identity.principalId
+output managedIdentityClientId string = webApp.identity.tenantId
diff --git a/README.md b/README.md
index 8b9564c..6338075 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -27,6 +27,8 @@
[Pipeline Setup](#settings-in-pipeline)
+[ADO integration Setup](#setup-azuredevops-optional)
+
## Grouping
To create a group for a subset of tables, you must simply add a "#" at the start of your table description. See below:
@@ -71,6 +73,18 @@ Add `Website/.env` file to run this locally.
openssl rand -base64 32
```
+## Setup AzureDevOps (optional)
+In 2.2.0 you can integrate with ado to save/load diagrams. The 2.2.0 pipelines automatically creates a Managed Identity (MI) to the web service, so configuration is minimal to enable this integration.
+
+1. Go to your Azure Dev Ops `Organization Settings > Users`.
+2. Click `Add users` and search for `wa-<>` where `<>` is the environment variable you use for the website name.
+3. Select the principle-user/MI from the search result, give `Basic` access level, add to correct ADO project, and UNTICK the `send email invites`.
+4. Navigate to your ADO project.
+5. Inside `Project Settings > Permissions > Users (at the top) > (Click on the MI) > Member of > Add > (e.g. Constributor)` (The MI should have at least read/write to your DataModelViewer repo)
+
+> Note: Constributor gives additional unnecessary permissions. We suggest a least priviledges role.
+
+
## Running it
Generate data by running the Generator project from Visual Studio.
Afterwards go into the "Website"-folder from VS Code and open the terminal (of the "Command Prompt" type). If this the first time running it, type `npm install` (you need to have installed node.js first: https://nodejs.org/en/download/). Start the website on localhost by running `npm run dev`. Click the link in the terminal to view the website.
@@ -93,6 +107,7 @@ The pipeline expects a variable group called `DataModel`. It must have the follo
* (Optional) AdoWikiName: Name of your wiki found under "Overview -> Wiki" in ADO. (will be encoded so dont worry about space)
* (Optional) AdoWikiPagePath: Path to the introduction page you wish to show in DMV. (will also be encoded so dont worry about spaces)
* (Optional) WebResourceNameFunc: Function to fetch the entity logicalname from a webresource. The function must be a valid C# LINQ expression that works on the `name` input parameter. Default: `np(name.Split('/').LastOrDefault()).Split('.').Reverse().Skip(1).FirstOrDefault()`
+* (Optional) AdoRepositoryName: Name of the existing repo to store diagrams. A folder will be created called `diagrams` first time being used.
## After deployment
* Go to portal.azure.com
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/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/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/ManagedIdentityAuthService.ts b/Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts
new file mode 100644
index 0000000..daaddb4
--- /dev/null
+++ b/Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts
@@ -0,0 +1,88 @@
+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 {
+ // 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,
+ ...authHeaders
+ }
+ });
+ }
+
+ getConfig(): AdoConfig {
+ return this.config;
+ }
+}
+
+export const managedAuth = new ManagedIdentityAuth();
\ No newline at end of file
diff --git a/Website/app/api/diagram/export-png/route.ts b/Website/app/api/diagram/export-png/route.ts
new file mode 100644
index 0000000..03ed82b
--- /dev/null
+++ b/Website/app/api/diagram/export-png/route.ts
@@ -0,0 +1,78 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { commitFileToRepo } from '../../services/AzureDevOpsService';
+
+interface PngExportRequest {
+ fileName: string;
+ imageData: string; // Base64 encoded PNG data
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const { fileName, imageData }: PngExportRequest = await request.json();
+
+ // Validate required fields
+ if (!fileName || !imageData) {
+ return NextResponse.json(
+ { error: 'File name and image data are required' },
+ { status: 400 }
+ );
+ }
+
+ // Ensure file name has .png extension
+ const normalizedFileName = fileName.endsWith('.png') ? fileName : `${fileName}.png`;
+
+ // Generate file path
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const sanitizedName = normalizedFileName.replace(/\.png$/, '').replace(/[^a-zA-Z0-9]/g, '_');
+ const fileNameWithTimestamp = `${sanitizedName}_${timestamp}.png`;
+ const filePath = `diagrams/exports/${fileNameWithTimestamp}`;
+
+ // imageData is already base64 encoded PNG from the client
+ // We need to pass it as binary data that commitFileToRepo will encode
+ // Decode base64 to buffer, then encode as latin1 string for transport
+ const binaryBuffer = Buffer.from(imageData, 'base64');
+
+ // IMPORTANT: Pass as latin1 to preserve exact binary data through string conversion
+ // commitFileToRepo will do Buffer.from(content) which defaults to utf8
+ // We need to prevent utf8 interpretation of binary data
+ // Solution: Pass the raw buffer data using latin1 encoding
+ const binaryContent = binaryBuffer.toString('latin1');
+
+ // Save to Azure DevOps repository
+ const commitMessage = `Export diagram as PNG: ${normalizedFileName}`;
+
+ // Pass isBinary flag so commitFileToRepo uses latin1 encoding
+ const result = await commitFileToRepo({
+ filePath,
+ content: binaryContent,
+ commitMessage,
+ branch: 'main',
+ repositoryName: process.env.ADO_REPOSITORY_NAME || '',
+ isUpdate: false,
+ isBinary: true // Important: tells service to use latin1 encoding for binary data
+ });
+
+ return NextResponse.json({
+ success: true,
+ message: 'PNG exported to cloud successfully',
+ filePath,
+ commitId: result.commitId,
+ fileName: fileNameWithTimestamp
+ });
+
+ } catch (error) {
+ console.error('Error exporting PNG to cloud:', error);
+
+ if (error instanceof Error) {
+ return NextResponse.json(
+ { error: `Failed to export PNG: ${error.message}` },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: 'Failed to export PNG: Unknown error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/Website/app/api/diagram/list/route.ts b/Website/app/api/diagram/list/route.ts
new file mode 100644
index 0000000..009e5bd
--- /dev/null
+++ b/Website/app/api/diagram/list/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from 'next/server';
+import { listFilesFromRepo, type GitItem } from '../../services/AzureDevOpsService';
+
+interface DiagramMetadata {
+ path: string;
+ name: string;
+}
+
+export async function GET() {
+ try {
+ // List files in the diagrams folder from Azure DevOps
+ const files = await listFilesFromRepo({
+ filePath: 'diagrams',
+ branch: 'main',
+ repositoryName: process.env.ADO_REPOSITORY_NAME || ''
+ });
+
+ // 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, ' ');
+
+ return {
+ path: file.path,
+ name: cleanName || nameWithoutExtension,
+ };
+ })
+
+ 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 instanceof Error ? error.message : 'Unknown error'}` },
+ { 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..9bfa1f8
--- /dev/null
+++ b/Website/app/api/diagram/load/route.ts
@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { pullFileFromRepo } from '../../services/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 pullFileFromRepo({
+ filePath,
+ branch: 'main',
+ repositoryName: process.env.ADO_REPOSITORY_NAME || ''
+ });
+
+ 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/repository-info/route.ts b/Website/app/api/diagram/repository-info/route.ts
new file mode 100644
index 0000000..7834589
--- /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.ADO_REPOSITORY_NAME || '';
+
+ // 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..1ad9c05
--- /dev/null
+++ b/Website/app/api/diagram/save/route.ts
@@ -0,0 +1,91 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { commitFileToRepo } from '../../services/AzureDevOpsService';
+import { SerializedDiagram } from '@/lib/diagram/models/serialized-diagram';
+
+interface DiagramSaveData extends SerializedDiagram {
+ overwriteFilePath?: 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 - 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: 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 commitFileToRepo({
+ filePath,
+ content: JSON.stringify(enrichedData, null, 2),
+ commitMessage,
+ branch: 'main',
+ repositoryName: process.env.ADO_REPOSITORY_NAME || '',
+ isUpdate: Boolean(diagramData.overwriteFilePath)
+ });
+
+ 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/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/services/AzureDevOpsService.ts b/Website/app/api/services/AzureDevOpsService.ts
new file mode 100644
index 0000000..94dc742
--- /dev/null
+++ b/Website/app/api/services/AzureDevOpsService.ts
@@ -0,0 +1,499 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { managedAuth } from '../auth/azuredevops/ManagedIdentityAuthService';
+
+interface CreateFileOptions {
+ filePath: string;
+ content: string;
+ commitMessage?: string;
+ branch?: string;
+ repositoryName?: string; // Optional override
+ isUpdate?: boolean; // Flag to indicate if this is updating an existing file
+ isBinary?: boolean; // Flag to indicate content is binary data encoded as latin1 string
+}
+
+interface LoadFileOptions {
+ filePath: string;
+ branch?: string;
+ repositoryName?: string; // Optional override
+}
+
+interface GitItem {
+ objectId: string;
+ gitObjectType: string;
+ commitId: string;
+ path: string;
+ isFolder: boolean;
+}
+
+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;
+}
+
+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);
+ this.name = 'AzureDevOpsError';
+ }
+}
+
+/**
+ * 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();
+
+ // 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/${repositoryName}/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 commitFileToRepo(options: CreateFileOptions): Promise {
+ const {
+ filePath,
+ content,
+ commitMessage = `Add ${filePath}`,
+ branch = 'main',
+ repositoryName,
+ isUpdate = false,
+ isBinary = false
+ } = options;
+
+ try {
+ // Get ADO configuration
+ const config = managedAuth.getConfig();
+
+ // Validate inputs
+ if (!filePath || content === undefined) {
+ throw new AzureDevOpsError('File path and content are required');
+ }
+
+ // Convert content to base64
+ // For binary data, content is latin1 encoded string, so we need to use latin1 encoding
+ // For text data, use default utf8 encoding
+ const base64Content = isBinary
+ ? Buffer.from(content, 'latin1').toString('base64')
+ : Buffer.from(content).toString('base64');
+
+ // Get the latest commit ID for the branch (needed for push operation)
+ const refsUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/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: isUpdate ? "edit" : "add",
+ item: {
+ path: filePath.startsWith('/') ? filePath : `/${filePath}`
+ },
+ newContent: {
+ content: base64Content,
+ contentType: "base64encoded"
+ }
+ }
+ ]
+ }
+ ]
+ };
+
+ // Push the changes
+ const pushUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/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 pullFileFromRepo(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');
+ }
+
+ // 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/${repositoryName}/items?path=/${normalizedPath}&versionDescriptor.version=${branch}&versionDescriptor.versionType=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: ${fileUrl}`);
+ }
+
+ 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: ${error instanceof Error ? error.message : String(error)}`);
+ }
+}
+
+/**
+ * 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`;
+
+ 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 {
+ 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,
+ FileVersionOptions,
+ LoadFileVersionOptions,
+ GitCommitResponse,
+ GitFileResponse,
+ GitItem,
+ FileVersion
+};
+export { AzureDevOpsError };
\ No newline at end of file
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/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/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..9613c76 100644
--- a/Website/app/layout.tsx
+++ b/Website/app/layout.tsx
@@ -4,8 +4,8 @@ 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";
export const metadata: Metadata = {
title: "Data Model Viewer",
@@ -29,13 +29,13 @@ export default function RootLayout({
-
+
{children}
-
+
diff --git a/Website/app/metadata/page.tsx b/Website/app/metadata/page.tsx
index b422cff..bacc9af 100644
--- a/Website/app/metadata/page.tsx
+++ b/Website/app/metadata/page.tsx
@@ -1,16 +1,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/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 */}
diff --git a/Website/components/datamodelview/SidebarDatamodelView.tsx b/Website/components/datamodelview/SidebarDatamodelView.tsx
index bfe8e6e..18c53d0 100644
--- a/Website/components/datamodelview/SidebarDatamodelView.tsx
+++ b/Website/components/datamodelview/SidebarDatamodelView.tsx
@@ -1,28 +1,21 @@
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 } 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 +71,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 +93,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 +119,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 +135,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]);
-
- const NavItem = useCallback(({ group }: INavItemProps) => {
- const isCurrentGroup = currentGroup?.toLowerCase() === group.Name.toLowerCase();
- const isExpanded = expandedGroups.has(group.Name) || isCurrentGroup;
+ }, [dataModelDispatch, scrollToSection, clearSearch, isMobile, closeSidebar]);
- 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 (
-
- )
- })}
-
-
-
- )
- }, [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 +167,23 @@ export const SidebarDatamodelView = ({ }: ISidebarDatamodelViewProps) => {
/>
- {
- filteredGroups.map((group) =>
-
- )
- }
+ {filteredGroups.map((group) => (
+
+ ))}
);
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/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 (
-
- );
-};
\ No newline at end of file
diff --git a/Website/components/diagramview/DiagramContainer.tsx b/Website/components/diagramview/DiagramContainer.tsx
new file mode 100644
index 0000000..12e16b5
--- /dev/null
+++ b/Website/components/diagramview/DiagramContainer.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+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";
+import { diagramEvents } from "@/lib/diagram/DiagramEventBridge";
+
+interface IDiagramContainerProps {
+
+}
+
+export default function DiagramContainer({ }: IDiagramContainerProps) {
+ const { canvas } = useDiagramView();
+ const [contextMenu, setContextMenu] = useState<{
+ open: boolean;
+ position: { top: number; left: number } | null;
+ entityId?: string;
+ }>({
+ open: false,
+ position: null
+ });
+
+ // Use the event bridge for diagram events
+ useEffect(() => {
+ const cleanup = diagramEvents.onContextMenuEvent((entityId, x, y) => {
+ setContextMenu({
+ open: true,
+ position: { top: y, left: x },
+ entityId: entityId
+ });
+ });
+
+ return cleanup;
+ }, []);
+
+ const handleCloseContextMenu = () => {
+ setContextMenu({
+ open: false,
+ position: null
+ });
+ };
+
+ return (
+
+
+
+
+
+ );
+}
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
-
-
- }
- sx={{ justifyContent: 'flex-start', textTransform: 'none' }}
- >
- Reset View
-
- }
- sx={{ justifyContent: 'flex-start', textTransform: 'none' }}
- >
- Fit to Screen
-
-
-
-
-
-
-
-
- Tools
-
-
- }
- sx={{ justifyContent: 'flex-start', textTransform: 'none' }}
- >
- Search Entities
-
- }
- sx={{ justifyContent: 'flex-start', textTransform: 'none' }}
- >
- Layer Manager
-
- }
- sx={{ justifyContent: 'flex-start', textTransform: 'none' }}
- >
- Diagram Settings
-
-
-
-
- );
-};
-
-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/DiagramHeaderToolbar.tsx b/Website/components/diagramview/DiagramHeaderToolbar.tsx
new file mode 100644
index 0000000..0e74fbb
--- /dev/null
+++ b/Website/components/diagramview/DiagramHeaderToolbar.tsx
@@ -0,0 +1,188 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Box, Chip, useTheme, alpha } from '@mui/material';
+import { HeaderDropdownMenu, MenuItemConfig } from './smaller-components/HeaderDropdownMenu';
+import { ArchiveIcon, CloudNewIcon, CloudSaveIcon, FileMenuIcon, LoadIcon, LocalSaveIcon, NewIcon, CloudExportIcon, ExportIcon } from '@/lib/icons';
+import { SaveDiagramModal } from './modals/SaveDiagramModal';
+import { LoadDiagramModal } from './modals/LoadDiagramModal';
+import { ExportOptionsModal } from './modals/ExportOptionsModal';
+import { VersionHistorySidepane } from './panes/VersionHistorySidepane';
+import { useDiagramSave } from '@/hooks/useDiagramSave';
+import { useDiagramLoad } from '@/hooks/useDiagramLoad';
+import { useDiagramExport } from '@/hooks/useDiagramExport';
+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, loadedDiagramFilePath} = useDiagramView();
+ const { isSaving, showSaveModal, saveDiagramToCloud, saveDiagramLocally, closeSaveModal, createNewDiagram } = useDiagramSave();
+ const {
+ isLoading,
+ isLoadingList,
+ showLoadModal,
+ availableDiagrams,
+ loadDiagramFromCloud,
+ loadDiagramFromFile,
+ loadAvailableDiagrams,
+ openLoadModal,
+ closeLoadModal
+ } = useDiagramLoad();
+ const { isExporting, showExportModal, exportTarget, exportDiagramLocallyAsPng, exportDiagramToCloudAsPng, performExport, closeExportModal } = useDiagramExport();
+ const { isCloudConfigured, isLoading: isRepoInfoLoading } = useRepositoryInfo();
+ const [showVersionHistory, setShowVersionHistory] = useState(false);
+
+ const theme = useTheme();
+
+ const fileMenuItems: MenuItemConfig[] = [
+ {
+ id: 'new',
+ label: 'New Diagram',
+ icon: NewIcon,
+ action: createNewDiagram,
+ disabled: false,
+ },
+ {
+ id: 'load',
+ label: 'Load',
+ icon: LoadIcon,
+ action: openLoadModal,
+ disabled: isLoading,
+ dividerAfter: true,
+ },
+ {
+ id: 'save',
+ label: 'Save to Cloud',
+ icon: CloudSaveIcon,
+ action: saveDiagramToCloud,
+ disabled: !isCloudConfigured || isSaving || !hasLoadedDiagram || loadedDiagramSource !== 'cloud',
+ },
+ {
+ id: 'save-new',
+ label: 'Create in Cloud',
+ icon: CloudNewIcon,
+ action: saveDiagramToCloud,
+ disabled: !isCloudConfigured || isSaving,
+ dividerAfter: true,
+ },
+ {
+ id: 'save-local',
+ label: 'Download',
+ icon: LocalSaveIcon,
+ action: saveDiagramLocally,
+ disabled: isSaving,
+ dividerAfter: true,
+ },
+ {
+ id: 'export-cloud',
+ label: 'Export to Cloud',
+ icon: CloudExportIcon,
+ action: exportDiagramToCloudAsPng,
+ disabled: !isCloudConfigured || isExporting,
+ },
+ {
+ id: 'export-local',
+ label: 'Download PNG',
+ icon: ExportIcon,
+ action: exportDiagramLocallyAsPng,
+ disabled: isExporting,
+ },
+ ];
+
+ return (
+ <>
+
+
+
+
+ setShowVersionHistory(true)}
+ new={false}
+ disabled={!hasLoadedDiagram || !loadedDiagramFilePath}
+ />
+
+
+
+ : }
+ 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 }
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+ setShowVersionHistory(false)}
+ />
+
+
+ >
+ );
+};
\ 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..141c33b 100644
--- a/Website/components/diagramview/DiagramView.tsx
+++ b/Website/components/diagramview/DiagramView.tsx
@@ -1,27 +1,17 @@
'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 DiagramContainer from "./DiagramContainer";
+import { DiagramHeaderToolbar } from "./DiagramHeaderToolbar";
+import { Box } from "@mui/material";
-export default function DiagramView({ }: IDiagramView) {
+interface IDiagramViewProps {
+
+}
+
+export default function DiagramView({ }: IDiagramViewProps) {
const { setElement, expand } = useSidebar();
useEffect(() => {
@@ -30,887 +20,11 @@ export default function DiagramView({ }: IDiagramView) {
}, [])
return (
-
+
+
+
+
+
+
);
-}
-
-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
);
}
\ 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/avoid-router/avoidrouter.ts b/Website/components/diagramview/avoid-router/shared/avoidrouter.ts
similarity index 99%
rename from Website/components/diagramview/avoid-router/avoidrouter.ts
rename to Website/components/diagramview/avoid-router/shared/avoidrouter.ts
index e676263..d645f8a 100644
--- a/Website/components/diagramview/avoid-router/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/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..7326dc3
--- /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));
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ 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.warn('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) => {
+ 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..40ea730
--- /dev/null
+++ b/Website/components/diagramview/avoid-router/worker-thread/worker.ts
@@ -0,0 +1,146 @@
+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;
+
+ const { command, ...data } = e.data; // Remove array destructuring
+ switch (command) {
+ case RouterRequestEvent.Reset: {
+ const { cells } = data;
+ graph.resetCells(cells || [], { fromBrowser: true });
+ router.routeAll();
+ break;
+ }
+ case RouterRequestEvent.Change: {
+ const { cell } = data;
+ 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;
+ const model = graph.getCell(id);
+ if (!model) break;
+ model.remove({ fromBrowser: true });
+ break;
+ }
+ case RouterRequestEvent.Add: {
+ const { cell } = data;
+ graph.addCell(cell, { fromBrowser: true });
+ break;
+ }
+ default:
+ console.warn('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
+});
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let changed: Record = {};
+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/EntityElement.ts b/Website/components/diagramview/diagram-elements/EntityElement.ts
new file mode 100644
index 0000000..f2dbb7c
--- /dev/null
+++ b/Website/components/diagramview/diagram-elements/EntityElement.ts
@@ -0,0 +1,208 @@
+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;
+ 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: {
+ 'mouseenter': 'onMouseEnter',
+ 'mouseleave': 'onMouseLeave',
+ 'contextmenu': 'onContextMenu',
+ 'pointerdown': 'onPointerDown',
+ 'pointerup': 'onPointerUp',
+ },
+
+ initialize: function (options?: IEntityOptions) {
+ dia.ElementView.prototype.initialize.call(this, options);
+ this.updateTitle();
+ this.isSelected = false; // Track selection state
+ },
+
+ onMouseEnter: function () {
+ // 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/border', '2px solid var(--mui-palette-primary-main)');
+ }
+ },
+
+ onMouseLeave: function () {
+ // 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/border', '2px solid var(--mui-palette-border-main)');
+ }
+ },
+
+ onContextMenu: function (evt: MouseEvent) {
+ evt.preventDefault();
+ evt.stopPropagation();
+
+ // Dispatch a custom event for context menu
+ diagramEvents.dispatchEntityContextMenu(
+ String(this.model.id),
+ evt.clientX,
+ evt.clientY
+ );
+ },
+
+ onPointerDown: function () {
+ this.model.attr('container/style/cursor', 'grabbing');
+
+ diagramEvents.dispatchEntitySelect(
+ String(this.model.id),
+ this.model.get('entityData')
+ );
+ },
+
+ onPointerUp: function () {
+ 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/border', '2px solid 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/border', '2px solid 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);
+ },
+
+ 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', {
+ size: { width: 120, height: 80 },
+ z: 10,
+ 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-border-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'
+ }
+ }
+ },
+ 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 */`
+
+
+
+
+
+ `
+});
+
+export function createEntity(options: IEntityOptions = {}) {
+ 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,
+ entityData: options.entityData
+ });
+
+ return entity;
+}
\ 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..b7901ef
--- /dev/null
+++ b/Website/components/diagramview/diagram-elements/RelationshipLink.ts
@@ -0,0 +1,190 @@
+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,
+ 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
+ },
+ wrapper: {
+ connection: true,
+ strokeWidth: 10,
+ strokeLinejoin: 'round'
+ }
+ }
+});
+
+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 () {
+ // Get the relationships array from the model
+ const relationships = this.model.get('relationshipInformationList') || [];
+
+ diagramEvents.dispatchRelationshipSelect(
+ String(this.model.id),
+ relationships
+ );
+ }
+});
+
+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
+};
+
+/**
+ * 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 createRelationshipLink = (
+ sourceId: dia.Cell.ID,
+ sourceSchemaName: string,
+ targetId: dia.Cell.ID,
+ targetSchemaName: string,
+ relationshipInformationList: RelationshipInformation[],
+ label?: string
+) => {
+ const link = new RelationshipLink({
+ source: { id: sourceId },
+ target: { id: targetId },
+ sourceSchemaName,
+ targetSchemaName,
+ relationshipInformationList,
+ });
+
+ // Add label if provided using JointJS label system
+ if (label) {
+ link.appendLabel({
+ markup: [
+ {
+ tagName: 'rect',
+ selector: 'body'
+ },
+ {
+ tagName: 'text',
+ selector: 'label'
+ }
+ ],
+ attrs: {
+ label: {
+ text: label,
+ fill: 'var(--mui-palette-text-primary)',
+ fontSize: 12,
+ fontFamily: 'sans-serif',
+ textAnchor: 'middle',
+ textVerticalAnchor: 'middle'
+ },
+ body: {
+ ref: 'label',
+ fill: 'white',
+ rx: 3,
+ ry: 3,
+ refWidth: '100%',
+ refHeight: '100%',
+ refX: '0%',
+ refY: '0%'
+ }
+ },
+ position: {
+ distance: 0.5,
+ args: {
+ keepGradient: true,
+ ensureLegibility: true
+ }
+ }
+ });
+ }
+
+ // 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)");
+ } else if (relationshipInformationList.every(rel => rel.isIncluded === false)) {
+ link.attr('line/style/strokeDasharray', '1 1');
+ link.attr('line/style/stroke', 'var(--mui-palette-text-secondary)');
+ link.attr('line/targetMarker', null);
+ link.attr('line/sourceMarker', null);
+ }
+
+ return link;
+}
\ No newline at end of file
diff --git a/Website/components/diagramview/diagram-elements/Selection.ts b/Website/components/diagramview/diagram-elements/Selection.ts
new file mode 100644
index 0000000..a209962
--- /dev/null
+++ b/Website/components/diagramview/diagram-elements/Selection.ts
@@ -0,0 +1,271 @@
+import { dia, g, mvc, V, VElement } from "@joint/core";
+import { diagramEvents } from "@/lib/diagram/DiagramEventBridge";
+import { EntityElementView } from "./EntityElement";
+
+export const SelectionElement = dia.Element.define('selection.SelectionElement', {
+ size: { width: 100, height: 100 },
+ attrs: {
+ body: {
+ cursor: 'move',
+ 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);
+
+ // 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: transformedX,
+ y: transformedY,
+ 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);
+
+ // 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
+ });
+ };
+
+ 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();
+ this.teardownSelectionElement();
+ diagramEvents.dispatchClear();
+
+ // 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 === 1) {
+ 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;
+
+ if (groupBBox === null) {
+ return;
+ }
+
+ // 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 {
+ 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);
+ (el.findView(this.paper) as EntityElementView).onSelect();
+ });
+
+ // 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 ---------------------------------------------------------------
+
+ 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);
+ // Call onDeselect to remove selection styling
+ (k.findView(this.paper) as EntityElementView).onDeselect();
+ }
+
+ // 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() {
+ // Call onDeselect on all currently selected elements
+ this.elements.toArray().forEach(el => {
+ (el.findView(this.paper) as EntityElementView).onDeselect();
+ });
+
+ this.elements.reset([]);
+ if (this.selectionElement) {
+ this.selectionElement.remove();
+ 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/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/events/SelectObjectEvent.ts b/Website/components/diagramview/events/SelectObjectEvent.ts
new file mode 100644
index 0000000..9bcce60
--- /dev/null
+++ b/Website/components/diagramview/events/SelectObjectEvent.ts
@@ -0,0 +1,8 @@
+import { RelationshipInformation } from "@/lib/diagram/models/relationship-information";
+import { EntityType } from "@/lib/Types";
+
+export type SelectObjectEvent = {
+ type: 'none' | 'entity' | 'selection' | 'relationship';
+ objectId: string | undefined;
+ data?: EntityType[] | RelationshipInformation[];
+}
\ 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/modals/ExportOptionsModal.tsx b/Website/components/diagramview/modals/ExportOptionsModal.tsx
new file mode 100644
index 0000000..dd19e77
--- /dev/null
+++ b/Website/components/diagramview/modals/ExportOptionsModal.tsx
@@ -0,0 +1,132 @@
+'use client';
+
+import React, { useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogActions,
+ Button,
+ Box,
+ FormControlLabel,
+ Checkbox,
+ Typography,
+ Divider,
+} from '@mui/material';
+
+export interface ExportOptions {
+ includeGrid: boolean;
+}
+
+interface ExportOptionsModalProps {
+ open: boolean;
+ onClose: () => void;
+ onExport: (options: ExportOptions) => void;
+ isExportingToCloud?: boolean;
+}
+
+export const ExportOptionsModal = ({
+ open,
+ onClose,
+ onExport,
+ isExportingToCloud = false
+}: ExportOptionsModalProps) => {
+ const [includeGrid, setIncludeGrid] = useState(false);
+
+ const handleExport = () => {
+ onExport({ includeGrid });
+ onClose();
+ };
+
+ const handleCancel = () => {
+ // Reset to defaults
+ setIncludeGrid(false);
+ onClose();
+ };
+
+ return (
+
+ );
+};
diff --git a/Website/components/diagramview/modals/LoadDiagramModal.tsx b/Website/components/diagramview/modals/LoadDiagramModal.tsx
new file mode 100644
index 0000000..c6a07e1
--- /dev/null
+++ b/Website/components/diagramview/modals/LoadDiagramModal.tsx
@@ -0,0 +1,198 @@
+'use client';
+
+import React, { useRef, useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ Box,
+ Typography,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemText,
+ ListItemIcon,
+ CircularProgress,
+ IconButton
+} from '@mui/material';
+import {
+ CloudDownload as CloudIcon,
+ Close as CloseIcon,
+ 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 { useRepositoryInfo } from '@/hooks/useRepositoryInfo';
+
+interface LoadDiagramModalProps {
+ open: boolean;
+ onClose: () => void;
+ availableDiagrams: DiagramFile[];
+ isLoadingList: boolean;
+ isLoading: boolean;
+ onLoadFromCloud: (filePath: string) => void;
+ onLoadFromFile: (file: File) => void;
+ onLoadAvailableDiagrams: () => void;
+}
+
+export const LoadDiagramModal = ({
+ open,
+ onClose,
+ availableDiagrams,
+ isLoadingList,
+ isLoading,
+ onLoadFromCloud,
+ 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];
+ if (file) {
+ onLoadFromFile(file);
+ // Reset the input
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ onClose();
+ }
+ };
+
+ const handleUploadClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleCloudClick = () => {
+ setShowCloudDiagrams(true);
+ onLoadAvailableDiagrams();
+ };
+
+ const handleBackToOptions = () => {
+ setShowCloudDiagrams(false);
+ };
+
+ const handleCloseModal = () => {
+ setShowCloudDiagrams(false);
+ onClose();
+ };
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/Website/components/diagramview/modals/SaveDiagramModal.tsx b/Website/components/diagramview/modals/SaveDiagramModal.tsx
new file mode 100644
index 0000000..64a3610
--- /dev/null
+++ b/Website/components/diagramview/modals/SaveDiagramModal.tsx
@@ -0,0 +1,126 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ Box,
+ Typography,
+} from '@mui/material';
+
+interface SaveDiagramModalProps {
+ open: boolean;
+ onClose?: () => void;
+}
+
+export const SaveDiagramModal = ({ open, onClose }: SaveDiagramModalProps) => {
+ 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 (
+
+ );
+};
\ No newline at end of file
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 (
-
- );
-};
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 (
-
- );
-};
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 (
-
- );
-};
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 (
-
- );
-};
diff --git a/Website/components/diagramview/panes/EntitySelectionPane.tsx b/Website/components/diagramview/panes/EntitySelectionPane.tsx
new file mode 100644
index 0000000..5a9b03b
--- /dev/null
+++ b/Website/components/diagramview/panes/EntitySelectionPane.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import React, { useState, useCallback } 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 EntitySelectionPaneProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const EntitySelectionPane = ({ open, onClose }: EntitySelectionPaneProps) => {
+ const { groups } = useDatamodelData();
+ const { addEntity, isEntityInDiagram } = useDiagramView();
+ const [expandedGroups, setExpandedGroups] = useState>(new Set());
+
+ const handleEntitySelect = useCallback((entity: EntityType) => {
+ addEntity(entity, undefined, entity.DisplayName);
+ }, []);
+
+ 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 (
+
+
+
+
+ Select Entity to Add
+
+
+
+
+
+
+
+
+ {groups.length === 0 ? (
+
+ No entity groups found. Please load datamodel data first.
+
+ ) : (
+
+ {groups.map((group: GroupType) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
\ No newline at end of file
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 (
-
- );
-};
diff --git a/Website/components/diagramview/panes/RelatedEntitiesPane.tsx b/Website/components/diagramview/panes/RelatedEntitiesPane.tsx
new file mode 100644
index 0000000..72cb3c7
--- /dev/null
+++ b/Website/components/diagramview/panes/RelatedEntitiesPane.tsx
@@ -0,0 +1,152 @@
+'use client';
+
+import React, { useState, useCallback, 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, isEntityInDiagram } = 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) => {
+ addEntity(
+ clickedEntity,
+ undefined,
+ clickedEntity.DisplayName
+ );
+ }, [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/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 (
-
- );
-};
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 (
-
- );
-};
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 (
-
- );
-};
diff --git a/Website/components/diagramview/panes/VersionHistorySidepane.tsx b/Website/components/diagramview/panes/VersionHistorySidepane.tsx
new file mode 100644
index 0000000..255393b
--- /dev/null
+++ b/Website/components/diagramview/panes/VersionHistorySidepane.tsx
@@ -0,0 +1,248 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import {
+ Drawer,
+ Box,
+ Typography,
+ IconButton,
+ List,
+ ListItem,
+ ListItemText,
+ Divider,
+ Alert,
+ Chip,
+ Skeleton,
+ Button,
+} 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/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/diagramview/smaller-components/EntityContextMenu.tsx b/Website/components/diagramview/smaller-components/EntityContextMenu.tsx
new file mode 100644
index 0000000..ea34d99
--- /dev/null
+++ b/Website/components/diagramview/smaller-components/EntityContextMenu.tsx
@@ -0,0 +1,40 @@
+'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
+}) => {
+ return (
+
+ );
+};
\ 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..632eb4d
--- /dev/null
+++ b/Website/components/diagramview/smaller-components/EntityProperties.tsx
@@ -0,0 +1,130 @@
+import { EntityType } from '@/lib/Types';
+import { ExtensionRounded, RestoreRounded } from '@mui/icons-material';
+import { Box, Divider, Typography, Button, Chip, Paper } from '@mui/material';
+import React, { useState, useMemo } from 'react';
+import { RelatedEntitiesPane } from '@/components/diagramview/panes/RelatedEntitiesPane';
+import { BinIcon, PathConnectionIcon } from '@/lib/icons';
+import { useDiagramView, ExcludedLinkMetadata } from '@/contexts/DiagramViewContext';
+
+interface IEntityPropertiesProps {
+ entity: EntityType | undefined;
+ closePane: () => void;
+}
+
+export default function EntityProperties({ entity, closePane }: IEntityPropertiesProps) {
+ const [relatedEntitiesPaneOpen, setRelatedEntitiesPaneOpen] = useState(false);
+ const { removeEntity, getExcludedLinks, restoreRelationshipLink } = useDiagramView();
+
+ const hasRelatedEntities = (entity?.Relationships ?? []).length > 0 ||
+ entity?.Attributes.some(attr => attr.AttributeType === 'LookupAttribute' && attr.Targets.length > 0);
+
+ // Get excluded relationships for this entity
+ const excludedRelationships = useMemo(() => {
+ const excludedLinks = getExcludedLinks();
+ const results: ExcludedLinkMetadata[] = [];
+
+ excludedLinks.forEach((link) => {
+ if (link.sourceSchemaName === entity?.SchemaName || link.targetSchemaName === entity?.SchemaName) {
+ results.push(link);
+ }
+ });
+
+ return results;
+ }, [entity?.SchemaName, getExcludedLinks]);
+
+ const handleRestoreLink = (link: ExcludedLinkMetadata) => {
+ restoreRelationshipLink(link.sourceSchemaName, link.targetSchemaName);
+ };
+
+ if (!entity) {
+ return (
+ Error: Entity not found.
+ )
+ }
+
+ return (
+
+ {entity.IconBase64 ?
+ : }
+ {entity?.DisplayName ?? 'Unknown Entity'}
+
+
+ {hasRelatedEntities && (
+ }
+ onClick={() => setRelatedEntitiesPaneOpen(true)}
+ fullWidth
+ >
+ View Related Entities
+
+ )}
+
+ {excludedRelationships.length > 0 && (
+ <>
+
+
+ Excluded Relationships
+
+
+ {excludedRelationships.length} hidden relationship{excludedRelationships.length !== 1 ? 's' : ''}
+
+
+ {excludedRelationships.map((link, index) => (
+
+
+
+ {link.sourceSchemaName} - {link.targetSchemaName}
+
+
+
+ }
+ onClick={() => handleRestoreLink(link)}
+ sx={{ minWidth: 'auto', px: 1 }}
+ >
+ Restore
+
+
+
+
+ ))}
+
+ >
+ )}
+
+
+
+ setRelatedEntitiesPaneOpen(false)}
+ entity={entity}
+ />
+
+ )
+}
diff --git a/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx
new file mode 100644
index 0000000..e0a76e8
--- /dev/null
+++ b/Website/components/diagramview/smaller-components/HeaderDropdownMenu.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import React, { useState } from 'react';
+import {
+ Menu,
+ MenuItem,
+ ListItemIcon,
+ ListItemText,
+ 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 (
+ <>
+
+
+
+ >
+ );
+};
\ 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..d898d7d
--- /dev/null
+++ b/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx
@@ -0,0 +1,62 @@
+import { ChevronRightRounded } from '@mui/icons-material';
+import { Tooltip, Box, Badge, alpha, Typography } from '@mui/material';
+import React from 'react'
+
+interface IHeaderMenuItemProps {
+ icon: React.ReactNode;
+ label: string;
+ tooltip?: string;
+ new?: boolean;
+ disabled?: boolean;
+ isDropdown?: boolean;
+ action?: (event: React.MouseEvent) => void;
+}
+
+const HeaderMenuItem = ({ icon, label, tooltip, new: isNew, disabled, action, isDropdown }: 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}
+
+ {isDropdown && (
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+export default HeaderMenuItem
diff --git a/Website/components/diagramview/smaller-components/RelationshipProperties.tsx b/Website/components/diagramview/smaller-components/RelationshipProperties.tsx
new file mode 100644
index 0000000..6782681
--- /dev/null
+++ b/Website/components/diagramview/smaller-components/RelationshipProperties.tsx
@@ -0,0 +1,131 @@
+import { useDiagramView } from '@/contexts/DiagramViewContext';
+import { RelationshipInformation } from '@/lib/diagram/models/relationship-information';
+import { Box, Chip, Paper, Typography, FormControlLabel, Switch, TextField, Divider } from '@mui/material';
+import React, { useState, useEffect } from 'react'
+
+interface IRelationshipPropertiesProps {
+ relationships: RelationshipInformation[];
+ linkId?: string;
+}
+
+const RelationshipProperties = ({ relationships, linkId }: IRelationshipPropertiesProps) => {
+
+ const { toggleRelationshipLink, updateRelationshipLinkLabel, getGraph } = useDiagramView();
+ const [isToggled, setIsToggled] = useState