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 @@

- logo + logo

@@ -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 ( -

-
- - {children} -
- ); -}; \ 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 - - - - - - - - - - - - Tools - - - - - - - - - ); -}; - -export const DiagramZoomDisplay: React.FC = () => { - const { zoom } = useDiagramViewContext(); - - return ( - - Zoom: {Math.round(zoom * 100)}% - - ); -}; - -export const DiagramZoomControls: React.FC = () => { - const { zoomIn, zoomOut } = useDiagramViewContext(); - - return ( - - - - - ); -}; \ No newline at end of file diff --git a/Website/components/diagramview/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>(new Map()); - - // Track previous diagram type to detect changes - const previousDiagramTypeRef = useRef(diagramType); - - // Wrapper for setSelectedKey to pass to renderer - const handleSetSelectedKey = useCallback((key: string | undefined) => { - setSelectedKey(key); - }, []); - - // Link click handler to pass to renderer - const handleLinkClick = useCallback((link: dia.Link) => { - setSelectedLink(link); - setIsLinkPropertiesSheetOpen(true); - }, []); - const [isEntityActionsSheetOpen, setIsEntityActionsSheetOpen] = useState(false); - const [selectedSquare, setSelectedSquare] = useState(null); - const [isSquarePropertiesSheetOpen, setIsSquarePropertiesSheetOpen] = useState(false); - const [selectedText, setSelectedText] = useState(null); - const [isTextPropertiesSheetOpen, setIsTextPropertiesSheetOpen] = useState(false); - const [selectedLink, setSelectedLink] = useState(null); - const [isLinkPropertiesSheetOpen, setIsLinkPropertiesSheetOpen] = useState(false); - const [isResizing, setIsResizing] = useState(false); - const [resizeData, setResizeData] = useState<{ - element: SquareElement; - handle: string; - startSize: { width: number; height: number }; - startPosition: { x: number; y: number }; - startPointer: { x: number; y: number }; - } | null>(null); - - const renderer = useMemo(() => { - if (!graph) return null; - - const RendererClass = (() => { - switch (diagramType) { - case 'simple': - return SimpleDiagramRenderer; - case 'detailed': - return DetailedDiagramRender; - default: - return SimpleDiagramRenderer; // fallback - } - })(); - - return new RendererClass(graph, { - setSelectedKey: handleSetSelectedKey, - onLinkClick: handleLinkClick - }); - }, [diagramType, graph, handleSetSelectedKey, handleLinkClick]); - - useEffect(() => { - if (Groups.length > 0 && !selectedGroup) { - selectGroup(Groups[0]); - } - }, [Groups, selectedGroup, selectGroup]); - - // Handle loading state when basic dependencies are ready - useEffect(() => { - if (graph && renderer) { // Remove paper dependency here since it might not be ready - // If we have the basic dependencies but no selected group or no entities, stop loading - if (!selectedGroup || currentEntities.length === 0) { - setIsLoading(false); - } - } - }, [graph, renderer, selectedGroup, currentEntities]); // Remove paper from dependencies - - useEffect(() => { - if (!renderer) return; - - // Bind the method to the renderer instance - const boundOnDocumentClick = renderer.onDocumentClick.bind(renderer); - document.addEventListener('click', boundOnDocumentClick); - return () => { - document.removeEventListener('click', boundOnDocumentClick); - }; - }, [renderer]); - - useEffect(() => { - if (!graph || !paper || !selectedGroup || !renderer) { - return; - } - - // Check if diagram type has changed and clear all positions if so - let diagramTypeChanged = false; - if (previousDiagramTypeRef.current !== diagramType) { - entityPositionsRef.current.clear(); - previousDiagramTypeRef.current = diagramType; - diagramTypeChanged = true; - } - - // Set loading state when starting diagram creation - setIsLoading(true); - - // If there are no entities, set loading to false immediately - if (currentEntities.length === 0) { - setIsLoading(false); - return; - } - - // Preserve squares, text elements, and existing entity positions before clearing - const squares = graph.getElements().filter(element => element.get('type') === 'delegate.square'); - const textElements = graph.getElements().filter(element => element.get('type') === 'delegate.text'); - const existingEntities = graph.getElements().filter(element => { - const entityData = element.get('data'); - return entityData?.entity; // This is an entity element - }); - - const squareData = squares.map(square => ({ - element: square, - data: square.get('data'), - position: square.position(), - size: square.size() - })); - const textData = textElements.map(textElement => ({ - element: textElement, - data: textElement.get('data'), - position: textElement.position(), - size: textElement.size() - })); - - // Update persistent position tracking with current positions - // Skip this if diagram type changed to ensure all entities are treated as new - if (!diagramTypeChanged) { - existingEntities.forEach(element => { - const entityData = element.get('data'); - if (entityData?.entity?.SchemaName) { - const position = element.position(); - entityPositionsRef.current.set(entityData.entity.SchemaName, position); - } - }); - } else { - } - - // Clean up position tracking for entities that are no longer in currentEntities - const currentEntityNames = new Set(currentEntities.map(e => e.SchemaName)); - for (const [schemaName] of entityPositionsRef.current) { - if (!currentEntityNames.has(schemaName)) { - entityPositionsRef.current.delete(schemaName); - } - } - - // Clear existing elements - graph.clear(); - - // Re-add preserved squares with their data - squareData.forEach(({ element, data, position, size }) => { - element.addTo(graph); - element.position(position.x, position.y); - element.resize(size.width, size.height); - element.set('data', data); - element.toBack(); // Keep squares at the back - }); - - // Re-add preserved text elements with their data - textData.forEach(({ element, data, position, size }) => { - element.addTo(graph); - element.position(position.x, position.y); - element.resize(size.width, size.height); - element.set('data', data); - element.toFront(); // Keep text elements at the front - }); - - // Calculate grid layout - const layoutOptions = getDefaultLayoutOptions(diagramType); - - // Get actual container dimensions - const containerRect = paper?.el?.getBoundingClientRect(); - const actualContainerWidth = containerRect?.width || layoutOptions.containerWidth; - const actualContainerHeight = containerRect?.height || layoutOptions.containerHeight; - - // Update layout options with actual container dimensions - const updatedLayoutOptions = { - ...layoutOptions, - containerWidth: actualContainerWidth, - containerHeight: actualContainerHeight, - diagramType: diagramType - }; - - // Separate new entities from existing ones using persistent position tracking - const newEntities = currentEntities.filter(entity => - !entityPositionsRef.current.has(entity.SchemaName) - ); - const existingEntitiesWithPositions = currentEntities.filter(entity => - entityPositionsRef.current.has(entity.SchemaName) - ); - - - // Store entity elements and port maps by SchemaName for easy lookup - const entityMap = new Map(); - const placedEntityPositions: { x: number; y: number; width: number; height: number }[] = []; - - // First, create existing entities with their preserved positions - existingEntitiesWithPositions.forEach((entity) => { - const position = entityPositionsRef.current.get(entity.SchemaName); - if (!position) return; // Skip if position is undefined - - const { element, portMap } = renderer.createEntity(entity, position); - entityMap.set(entity.SchemaName, { element, portMap }); - - // Track this position for collision avoidance - const dimensions = estimateEntityDimensions(entity, diagramType); - placedEntityPositions.push({ - x: position.x, - y: position.y, - width: dimensions.width, - height: dimensions.height - }); - }); - - - // Then, create new entities with grid layout that avoids already placed entities - if (newEntities.length > 0) { - // Calculate actual heights for new entities based on diagram type - const entityHeights = newEntities.map(entity => calculateEntityHeight(entity, diagramType)); - const maxEntityHeight = Math.max(...entityHeights, layoutOptions.entityHeight); - - const adjustedLayoutOptions = { - ...updatedLayoutOptions, - entityHeight: maxEntityHeight, - diagramType: diagramType - }; - - - const layout = calculateGridLayout(newEntities, adjustedLayoutOptions, placedEntityPositions); - - // Create new entities with grid layout positions - newEntities.forEach((entity, index) => { - const position = layout.positions[index] || { x: 50, y: 50 }; - const { element, portMap } = renderer.createEntity(entity, position); - entityMap.set(entity.SchemaName, { element, portMap }); - - // Update persistent position tracking for newly placed entities - entityPositionsRef.current.set(entity.SchemaName, position); - }); - } else { - } - - util.nextFrame(() => { - currentEntities.forEach(entity => { - renderer.createLinks(entity, entityMap, currentEntities); - }); - }); - - // Auto-fit to screen after a short delay to ensure all elements are rendered - setTimeout(() => { - fitToScreen(); - // Set loading to false once diagram is complete - setIsLoading(false); - }, 200); - }, [graph, paper, selectedGroup, currentEntities, diagramType]); - - useEffect(() => { - if (!graph || !renderer) return; - - // Sync the renderer's internal selectedKey state - renderer.updateSelectedKey(selectedKey); - - // Reset all links to default color first - graph.getLinks().forEach(link => { - link.attr('line/stroke', '#42a5f5'); - link.attr('line/strokeWidth', 2); - link.attr('line/targetMarker/stroke', '#42a5f5'); - link.attr('line/targetMarker/fill', '#42a5f5'); - link.attr('line/sourceMarker/stroke', '#42a5f5'); - }); - - // Only highlight if there's a selected key - if (selectedKey) { - renderer.highlightSelectedKey(graph, currentEntities, selectedKey); - } - }, [selectedKey, graph, currentEntities, renderer]); - - useEffect(() => { - if (!graph || !renderer) return; - renderer.updateEntityAttributes(graph, selectedKey); - }, [selectedKey, graph, renderer]); - - useEffect(() => { - if (!paper || !renderer) return; - - // Handle link clicks - paper.on('link:pointerclick', renderer.onLinkClick); - - // Handle entity clicks - const handleElementClick = (elementView: dia.ElementView, evt: dia.Event) => { - evt.stopPropagation(); - const element = elementView.model; - const elementType = element.get('type'); - - // Check if Ctrl is pressed - if so, skip opening any panes (selection is handled in useDiagram) - const isCtrlPressed = (evt.originalEvent as MouseEvent)?.ctrlKey || (evt.originalEvent as MouseEvent)?.metaKey; - if (isCtrlPressed) { - return; - } - - if (elementType === 'delegate.square') { - const squareElement = element as SquareElement; - - // Only open properties panel for squares (resize handles are shown on hover) - setSelectedSquare(squareElement); - setIsSquarePropertiesSheetOpen(true); - return; - } - - if (elementType === 'delegate.text') { - const textElement = element as TextElement; - - // Open properties panel for text elements - setSelectedText(textElement); - setIsTextPropertiesSheetOpen(true); - return; - } - - // Handle entity clicks - // Check if the click target is an attribute button - const target = evt.originalEvent?.target as HTMLElement; - const isAttributeButton = target?.closest('button[data-schema-name]'); - - // If clicking on an attribute, let the renderer handle it and don't open the entity actions sheet - if (isAttributeButton) { - return; - } - - const entityData = element.get('data'); - - if (entityData?.entity) { - setSelectedEntityForActions(entityData.entity.SchemaName); - setIsEntityActionsSheetOpen(true); - } - }; - - // Handle element hover for cursor indication - const handleElementMouseEnter = (elementView: dia.ElementView) => { - const element = elementView.model; - const elementType = element.get('type'); - - if (elementType === 'delegate.square') { - // Handle square hover - elementView.el.style.cursor = 'pointer'; - // Add a subtle glow effect for squares - element.attr('body/filter', 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.5))'); - - // Don't show resize handles on general hover - only on edge hover - return; - } - - if (elementType === 'delegate.text') { - // Handle text hover - elementView.el.style.cursor = 'pointer'; - // Add a subtle glow effect for text elements - element.attr('body/filter', 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.5))'); - return; - } - - // Handle entity hover using centralized style manager - const entityData = element.get('data'); - - if (entityData?.entity && paper) { - entityStyleManager.handleEntityMouseEnter(element, paper); - } - }; - - const handleElementMouseLeave = (elementView: dia.ElementView) => { - const element = elementView.model; - const elementType = element.get('type'); - - if (elementType === 'delegate.square') { - // Handle square hover leave - elementView.el.style.cursor = 'default'; - // Remove glow effect - element.attr('body/filter', 'none'); - - // Hide resize handles when leaving square area (unless selected for properties) - const squareElement = element as SquareElement; - if (selectedSquare?.id !== squareElement.id) { - squareElement.hideResizeHandles(); - } - return; - } - - if (elementType === 'delegate.text') { - // Handle text hover leave - elementView.el.style.cursor = 'default'; - // Remove glow effect - element.attr('body/filter', 'none'); - return; - } - - // Handle entity hover leave using centralized style manager - const entityData = element.get('data'); - - if (entityData?.entity && paper) { - entityStyleManager.handleEntityMouseLeave(element, paper); - } - }; - - paper.on('element:pointerclick', handleElementClick); - paper.on('element:mouseenter', handleElementMouseEnter); - paper.on('element:mouseleave', handleElementMouseLeave); - - // Handle mouse movement over squares to show resize handles only near edges - const handleSquareMouseMove = (cellView: dia.CellView, evt: dia.Event) => { - const element = cellView.model; - const elementType = element.get('type'); - - if (elementType === 'delegate.square') { - const squareElement = element as SquareElement; - const bbox = element.getBBox(); - - // Check if clientX and clientY are defined before using them - if (evt.clientX === undefined || evt.clientY === undefined) return; - - const paperLocalPoint = paper.clientToLocalPoint(evt.clientX, evt.clientY); - - const edgeThreshold = 15; // pixels from edge to show handles - const isNearEdge = ( - // Near left or right edge - (paperLocalPoint.x <= bbox.x + edgeThreshold || - paperLocalPoint.x >= bbox.x + bbox.width - edgeThreshold) || - // Near top or bottom edge - (paperLocalPoint.y <= bbox.y + edgeThreshold || - paperLocalPoint.y >= bbox.y + bbox.height - edgeThreshold) - ); - - if (isNearEdge) { - squareElement.showResizeHandles(); - cellView.el.style.cursor = 'move'; - } else { - // Only hide if not selected for properties (check current state) - const currentSelectedSquare = selectedSquare; - if (currentSelectedSquare?.id !== squareElement.id) { - squareElement.hideResizeHandles(); - } - cellView.el.style.cursor = 'move'; - } - } - }; - - paper.on('cell:mousemove', handleSquareMouseMove); - - // Handle pointer down for resize handles - capture before other events - paper.on('cell:pointerdown', (cellView: dia.CellView, evt: dia.Event) => { - const element = cellView.model; - const elementType = element.get('type'); - - if (elementType === 'delegate.square') { - const target = evt.target as HTMLElement; - - // More reliable selector detection for resize handles - let selector = target.getAttribute('joint-selector'); - - if (!selector) { - // Try to find parent with selector - let parent = target.parentElement; - let depth = 0; - while (parent && !selector && depth < 5) { - selector = parent.getAttribute('joint-selector'); - parent = parent.parentElement; - depth++; - } - } - - if (selector && selector.startsWith('resize-')) { - evt.stopPropagation(); - evt.preventDefault(); - - const squareElement = element as SquareElement; - const bbox = element.getBBox(); - - const resizeInfo = { - element: squareElement, - handle: selector, - startSize: { width: bbox.width, height: bbox.height }, - startPosition: { x: bbox.x, y: bbox.y }, - startPointer: { x: evt.clientX || 0, y: evt.clientY || 0 } - }; - - setResizeData(resizeInfo); - setIsResizing(true); - } - } - }); - - return () => { - paper.off('link:pointerclick', renderer.onLinkClick); - paper.off('element:pointerclick', handleElementClick); - paper.off('element:mouseenter', handleElementMouseEnter); - paper.off('element:mouseleave', handleElementMouseLeave); - paper.off('cell:mousemove', handleSquareMouseMove); - paper.off('cell:pointerdown'); - }; - }, [paper, renderer, selectedSquare]); - - // Handle resize operations - useEffect(() => { - if (!isResizing || !resizeData || !paper) return; - - let animationId: number; - - const handleMouseMove = (evt: MouseEvent) => { - if (!resizeData) return; - - // Cancel previous animation frame to prevent stacking - if (animationId) { - cancelAnimationFrame(animationId); - } - - // Use requestAnimationFrame for smooth updates - animationId = requestAnimationFrame(() => { - const { element, handle, startSize, startPosition, startPointer } = resizeData; - const deltaX = evt.clientX - startPointer.x; - const deltaY = evt.clientY - startPointer.y; - - // Adjust deltas based on paper scaling and translation - const scale = paper.scale(); - const adjustedDeltaX = deltaX / scale.sx; - const adjustedDeltaY = deltaY / scale.sy; - - const newSize = { width: startSize.width, height: startSize.height }; - const newPosition = { x: startPosition.x, y: startPosition.y }; - - // Calculate new size and position based on resize handle - switch (handle) { - case 'resize-se': // Southeast - newSize.width = Math.max(50, startSize.width + adjustedDeltaX); - newSize.height = Math.max(30, startSize.height + adjustedDeltaY); - break; - case 'resize-sw': // Southwest - newSize.width = Math.max(50, startSize.width - adjustedDeltaX); - newSize.height = Math.max(30, startSize.height + adjustedDeltaY); - newPosition.x = startPosition.x + adjustedDeltaX; - break; - case 'resize-ne': // Northeast - newSize.width = Math.max(50, startSize.width + adjustedDeltaX); - newSize.height = Math.max(30, startSize.height - adjustedDeltaY); - newPosition.y = startPosition.y + adjustedDeltaY; - break; - case 'resize-nw': // Northwest - newSize.width = Math.max(50, startSize.width - adjustedDeltaX); - newSize.height = Math.max(30, startSize.height - adjustedDeltaY); - newPosition.x = startPosition.x + adjustedDeltaX; - newPosition.y = startPosition.y + adjustedDeltaY; - break; - case 'resize-e': // East - newSize.width = Math.max(50, startSize.width + adjustedDeltaX); - break; - case 'resize-w': // West - newSize.width = Math.max(50, startSize.width - adjustedDeltaX); - newPosition.x = startPosition.x + adjustedDeltaX; - break; - case 'resize-s': // South - newSize.height = Math.max(30, startSize.height + adjustedDeltaY); - break; - case 'resize-n': // North - newSize.height = Math.max(30, startSize.height - adjustedDeltaY); - newPosition.y = startPosition.y + adjustedDeltaY; - break; - } - - // Apply the new size and position in a single batch update - element.resize(newSize.width, newSize.height); - element.position(newPosition.x, newPosition.y); - }); - }; - - const handleMouseUp = () => { - if (animationId) { - cancelAnimationFrame(animationId); - } - setIsResizing(false); - setResizeData(null); - }; - - // Add global event listeners - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - // Cleanup - return () => { - if (animationId) { - cancelAnimationFrame(animationId); - } - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isResizing, resizeData, paper]); - - // Handle clicking outside to deselect squares and manage area selection - useEffect(() => { - if (!paper) return; - - const handleBlankClick = () => { - if (selectedSquare) { - selectedSquare.hideResizeHandles(); - setSelectedSquare(null); - setIsSquarePropertiesSheetOpen(false); - } - } - - const handleBlankPointerDown = (_: dia.Event, x: number, y: number) => { - - // Don't set selected area if we were panning - if (!isPanning) { - setSelectedArea({ - ...selectedArea, - start: { x, y } - }); - } - }; - - const handleBlankPointerUp = (evt: dia.Event, x: number, y: number) => { - if (!isPanning && Math.abs(selectedArea.start.x - x) > 10 && Math.abs(selectedArea.start.y - y) > 10) { - // TODO - } - }; - - paper.on('blank:pointerdown', handleBlankPointerDown); - paper.on('blank:pointerup', handleBlankPointerUp); - paper.on('blank:pointerclick', handleBlankClick); - - return () => { - paper.off('blank:pointerdown', handleBlankPointerDown); - paper.off('blank:pointerup', handleBlankPointerUp); - paper.off('blank:pointerclick', handleBlankClick); - }; - }, [paper, selectedSquare, isPanning, selectedArea]); - - const handleAddAttribute = (attribute: AttributeType) => { - if (!selectedEntityForActions || !renderer) return; - addAttributeToEntity(selectedEntityForActions, attribute, renderer); - }; - - const handleRemoveAttribute = (attribute: AttributeType) => { - if (!selectedEntityForActions || !renderer) return; - removeAttributeFromEntity(selectedEntityForActions, attribute, renderer); - }; - - const handleDeleteEntity = () => { - if (selectedEntityForActions) { - removeEntityFromDiagram(selectedEntityForActions); - setIsEntityActionsSheetOpen(false); - setSelectedEntityForActions(undefined); - } - }; - - const handleDeleteSquare = () => { - if (selectedSquare && graph) { - // Remove the square from the graph - selectedSquare.remove(); - // Clear the selection - setSelectedSquare(null); - setIsSquarePropertiesSheetOpen(false); - } - }; - - const handleDeleteText = () => { - if (selectedText && graph) { - // Remove the text from the graph - selectedText.remove(); - // Clear the selection - setSelectedText(null); - setIsTextPropertiesSheetOpen(false); - } - }; - - const handleUpdateLink = (linkId: string | number, properties: LinkProperties) => { - if (!graph) return; - - const link = graph.getCell(linkId) as dia.Link; - if (!link) return; - - // Update link appearance - link.attr('line/stroke', properties.color); - link.attr('line/strokeWidth', properties.strokeWidth); - link.attr('line/targetMarker/stroke', properties.color); - link.attr('line/targetMarker/fill', properties.color); - link.attr('line/sourceMarker/stroke', properties.color); - - if (properties.strokeDasharray) { - link.attr('line/strokeDasharray', properties.strokeDasharray); - } else { - link.removeAttr('line/strokeDasharray'); - } - - // Update or remove label - if (properties.label) { - link.label(0, { - attrs: { - rect: { - fill: 'white', - stroke: '#e5e7eb', - strokeWidth: 1, - rx: 4, - ry: 4, - ref: 'text', - refX: -8, - refY: 0, - refWidth: '100%', - refHeight: '100%', - refWidth2: 16, - refHeight2: 8, - }, - text: { - text: properties.label, - fill: properties.color, - fontSize: 14, - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - textAnchor: 'start', - dominantBaseline: 'central', - } - }, - position: { - distance: 0.5, - offset: -1 - } - }); - } else { - link.removeLabel(0); - } - }; - - // Find the selected entity for actions - const selectedEntityForActionsData = currentEntities.find(entity => entity.SchemaName === selectedEntityForActions); - - // Find the entity display name for the modal - const selectedEntity = currentEntities.find(entity => entity.SchemaName === selectedEntityForActions); - - // Get available and visible attributes for the selected entity - const availableAttributes = selectedEntity?.Attributes || []; - const visibleAttributes = selectedEntity && renderer - ? renderer.getVisibleAttributes(selectedEntity) - : []; - - return ( - <> -
- {/* Beta Disclaimer Banner */} - - } - sx={{ - borderRadius: 0, - '& .MuiAlert-message': { - display: 'flex', - alignItems: 'center', - gap: 1, - flexWrap: 'wrap' - } - }} - > - - - Open Beta Feature: - {' '}This ER Diagram feature is currently in beta. Some functionality may not work fully. - {' '}We do not recommend more than 20 entities - - - - - - {/* Diagram Area */} - - {isLoading && ( - theme.palette.mode === 'dark' - ? 'rgba(17, 24, 39, 0.5)' - : 'rgba(248, 250, 252, 0.5)' - }} - > - - - {[...Array(3)].map((_, i) => ( - - ))} - - - Loading diagram... - - - - )} - - {/* Zoom and Coordinate Indicator */} - - - -
- - {/* Entity Actions Pane */} - - - {/* Square Properties Pane */} - - - {/* Text Properties Pane */} - - - {/* Link Properties Pane */} - { - setIsLinkPropertiesSheetOpen(open); - if (!open) setSelectedLink(null); - }} - selectedLink={selectedLink} - onUpdateLink={handleUpdateLink} - /> - - ) -}; +} \ No newline at end of file diff --git a/Website/components/diagramview/GridLayoutManager.ts b/Website/components/diagramview/GridLayoutManager.ts deleted file mode 100644 index 9b32c07..0000000 --- a/Website/components/diagramview/GridLayoutManager.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { EntityType } from '@/lib/Types'; -import { EntityElement } from '@/components/diagramview/elements/EntityElement'; - -export type DiagramType = 'simple' | 'detailed'; - -export interface GridLayoutOptions { - containerWidth: number; - containerHeight: number; - entityWidth: number; - entityHeight: number; - padding: number; - margin: number; - diagramType?: DiagramType; -} - -export interface GridPosition { - x: number; - y: number; -} - -export interface GridLayoutResult { - positions: GridPosition[]; - gridWidth: number; - gridHeight: number; - columns: number; - rows: number; -} - -/** - * Calculates the actual height of an entity based on its visible attributes and diagram type - */ -export const calculateEntityHeight = (entity: EntityType, diagramType: DiagramType = 'detailed'): number => { - // For simple diagrams, use fixed small dimensions - if (diagramType === 'simple') { - return 80; // Fixed height for simple entities - } - - // For detailed diagrams, calculate based on content - const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); - const itemHeight = 28; - const itemYSpacing = 8; - const addButtonHeight = 32; // Space for add button - const headerHeight = 80; - const startY = headerHeight + itemYSpacing * 2; - - // Calculate height including the add button - return startY + visibleItems.length * (itemHeight + itemYSpacing) + addButtonHeight + itemYSpacing; -}; - -/** - * Calculates optimal grid layout for entities based on screen aspect ratio - * Optionally avoids existing entity positions - */ -export const calculateGridLayout = ( - entities: EntityType[], - options: GridLayoutOptions, - existingPositions?: { x: number; y: number; width: number; height: number }[] -): GridLayoutResult => { - const { containerWidth, padding, margin } = options; - - if (entities.length === 0) { - return { - positions: [], - gridWidth: 0, - gridHeight: 0, - columns: 0, - rows: 0 - }; - } - - // If we have existing positions, we need to find the best starting position for new entities - let startColumn = 0; - let startRow = 0; - - if (existingPositions && existingPositions.length > 0) { - // Find the rightmost and bottommost positions - const maxX = Math.max(...existingPositions.map(pos => pos.x + pos.width)); - const maxY = Math.max(...existingPositions.map(pos => pos.y + pos.height)); - - // Get sample entity dimensions for spacing calculations - const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }, options.diagramType); - - // Start new entities to the right of existing ones, or on the next row - startColumn = Math.floor((maxX + padding - margin) / (sampleDimensions.width + padding)); - if (startColumn * (sampleDimensions.width + padding) + margin + sampleDimensions.width > containerWidth) { - // Move to next row if we can't fit horizontally - startColumn = 0; - startRow = Math.floor((maxY + padding - margin) / (sampleDimensions.height + padding)); - } - } - - // Determine how many columns can fit based on actual entity dimensions - const sampleEntityDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }, options.diagramType); - const actualEntityWidth = sampleEntityDimensions.width; - const maxColumns = Math.max(1, Math.floor((containerWidth - margin * 2 + padding) / (actualEntityWidth + padding))); - - // For collision avoidance, we'll place entities sequentially from the calculated starting position - const positions: GridPosition[] = []; - let currentColumn = startColumn; - let currentRow = startRow; - - for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - const entityDimensions = estimateEntityDimensions(entity, options.diagramType); - const height = entityDimensions.height; - const width = entityDimensions.width; - - // Find next available position that doesn't collide - let foundValidPosition = false; - let attempts = 0; - const maxAttempts = maxColumns * 10; // Prevent infinite loop - - while (!foundValidPosition && attempts < maxAttempts) { - // If we exceed the max columns, move to next row - if (currentColumn >= maxColumns) { - currentColumn = 0; - currentRow++; - } - - const x = margin + currentColumn * (width + padding); - const y = margin + currentRow * (height + padding); - - // Check if this position is occupied by existing entities - const isOccupied = existingPositions && existingPositions.length > 0 ? existingPositions.some(pos => { - const entityRight = x + width; - const entityBottom = y + height; - const existingRight = pos.x + pos.width; - const existingBottom = pos.y + pos.height; - - // Check for overlap with padding buffer - const buffer = padding / 4; - return !(entityRight + buffer < pos.x || - x > existingRight + buffer || - entityBottom + buffer < pos.y || - y > existingBottom + buffer); - }) : false; - - if (!isOccupied) { - positions.push({ x, y }); - foundValidPosition = true; - } - - // Move to next position - currentColumn++; - attempts++; - } - - if (!foundValidPosition) { - // Fallback: place at calculated position anyway (should not happen with enough attempts) - const x = margin + currentColumn * (width + padding); - const y = margin + currentRow * (height + padding); - positions.push({ x, y }); - currentColumn++; - } - } - - const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }, options.diagramType); - const gridWidth = Math.min(entities.length, maxColumns) * sampleDimensions.width + (Math.min(entities.length, maxColumns) - 1) * padding; - const gridHeight = (currentRow + 1) * (sampleDimensions.height + padding) - padding; - - return { - positions, - gridWidth, - gridHeight, - columns: Math.min(entities.length, maxColumns), - rows: currentRow + 1 - }; -}; - - -/** - * Estimates entity dimensions based on content and diagram type - */ -export const estimateEntityDimensions = (entity: EntityType, diagramType: DiagramType = 'detailed'): { width: number; height: number } => { - if (diagramType === 'simple') { - // Fixed dimensions for simple entities - return { - width: 200, - height: 80 - }; - } - - // Base dimensions for detailed entities - const baseWidth = 480; // Match the entity width used in EntityElement - const height = calculateEntityHeight(entity, diagramType); // Use actual calculated height - - return { - width: baseWidth, - height: height - }; -}; - -/** - * Gets default layout options based on diagram type - */ -export const getDefaultLayoutOptions = (diagramType: DiagramType = 'detailed'): GridLayoutOptions => { - if (diagramType === 'simple') { - return { - containerWidth: 1920, - containerHeight: 1080, - entityWidth: 200, // Smaller width for simple entities - entityHeight: 80, // Smaller height for simple entities - padding: 40, // Less padding for simple diagrams - margin: 40, // Less margin for simple diagrams - diagramType: 'simple' - }; - } - - return { - containerWidth: 1920, // Use a wider default container - containerHeight: 1080, // Use a taller default container - entityWidth: 480, - entityHeight: 400, // This will be overridden by actual calculation - padding: 80, // Reduced padding for better space utilization - margin: 80, - diagramType: 'detailed' - }; -}; \ No newline at end of file diff --git a/Website/components/diagramview/PropertiesPanel.tsx b/Website/components/diagramview/PropertiesPanel.tsx new file mode 100644 index 0000000..aedb6ba --- /dev/null +++ b/Website/components/diagramview/PropertiesPanel.tsx @@ -0,0 +1,96 @@ +import React, { useState, useEffect, useRef } from 'react' +import { Box, Divider, IconButton } from '@mui/material'; +import EntityProperties from './smaller-components/EntityProperties'; +import { SelectionProperties } from './smaller-components/SelectionProperties'; +import { diagramEvents } from '@/lib/diagram/DiagramEventBridge'; +import { SelectObjectEvent } from './events/SelectObjectEvent'; +import { EntityType } from '@/lib/Types'; +import { RelationshipInformation } from '@/lib/diagram/models/relationship-information'; +import RelationshipProperties from './smaller-components/RelationshipProperties'; +import { ChevronLeftRounded, ChevronRightRounded } from '@mui/icons-material'; + +interface IPropertiesPanelProps { + +} + +export default function PropertiesPanel({ }: IPropertiesPanelProps) { + const [object, setObject] = useState(null); + const objectRef = useRef(null); + + const [isForcedClosed, setIsForcedClosed] = useState(false); + const userClosedRef = useRef(false); + + const [isOpen, setIsOpen] = useState(false); + + const togglePane = () => { + if (isForcedClosed) { + setIsForcedClosed(false); + setIsOpen(true); + } else { + setIsForcedClosed(true); + setIsOpen(false); + } + } + + useEffect(() => { + userClosedRef.current = isForcedClosed; + }, [isForcedClosed]) + + useEffect(() => { + objectRef.current = object; + }, [object]); + + useEffect(() => { + const cleanup = diagramEvents.onSelectionEvent((event) => { + if (event.type === 'entity' && objectRef.current?.type === 'selection') { + return; + } + setObject(event); + setIsOpen(!userClosedRef.current && event.type !== "none"); + }); + + return cleanup; + }, []); + + const getProperties = () => { + switch (object?.type) { + case 'entity': + return ; + case 'relationship': + return ; + case 'selection': + return ; + } + } + + return ( + + + {isOpen ? : } + + + { + isOpen && ( + + {getProperties()} + + ) + } + + ) +} diff --git a/Website/components/diagramview/SidebarDiagramView.tsx b/Website/components/diagramview/SidebarDiagramView.tsx index 2ddd67e..7ab5793 100644 --- a/Website/components/diagramview/SidebarDiagramView.tsx +++ b/Website/components/diagramview/SidebarDiagramView.tsx @@ -1,369 +1,105 @@ +import { Box, Tooltip, Typography, Grid, TextField, Divider, Alert } from '@mui/material'; import React, { useState } from 'react'; -import { - Tabs, - Tab, - Box, - Button, - Collapse, - Typography, - Divider, -} from '@mui/material'; -import { CheckBoxOutlineBlankRounded, ChevronRightRounded, DeleteRounded, DriveFolderUploadRounded, ExpandMoreRounded, FolderRounded, HardwareRounded, PeopleRounded, RttRounded, SaveRounded, SettingsRounded, SmartphoneRounded, SyncRounded } from '@mui/icons-material'; -import { useDiagramViewContextSafe } from '@/contexts/DiagramViewContext'; -import { AddEntityPane, AddGroupPane, ResetToGroupPane } from '@/components/diagramview/panes'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { GroupType } from '@/lib/Types'; -import CustomTabPanel from '../shared/elements/TabPanel'; +import { EntitySelectionPane } from './panes/EntitySelectionPane'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; -interface ISidebarDiagramViewProps { +interface ISidebarDiagramViewProps { } +interface DiagramTool { + id: string; + label: string; + icon: React.ReactNode; + action: () => void; +} + export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { - const diagramContext = useDiagramViewContextSafe(); - const isMobile = useIsMobile(); - const [isDataExpanded, setIsDataExpanded] = useState(true); - const [isGeneralExpanded, setIsGeneralExpanded] = useState(false); - const [isEntitySheetOpen, setIsEntitySheetOpen] = useState(false); - const [isGroupSheetOpen, setIsGroupSheetOpen] = useState(false); - const [isResetSheetOpen, setIsResetSheetOpen] = useState(false); - const [tab, setTab] = useState(0); + const [entityPaneOpen, setEntityPaneOpen] = useState(false); - // If not in diagram context, show a message or return null - if (!diagramContext) { - return ( -
-
- -

Diagram tools are only available on the diagram page.

-
-
- ); - } + const { loadedDiagramFilename, loadedDiagramSource, hasLoadedDiagram, diagramName, setDiagramName } = useDiagramView(); - const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType, clearDiagram } = diagramContext; + const handleAddEntity = () => { + setEntityPaneOpen(true); + }; - const handleLoadDiagram = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - loadDiagram(file).catch(error => { - alert('Failed to load diagram: ' + error.message); - }); - } - // Reset input value to allow loading the same file again - event.target.value = ''; + const handleClosePane = () => { + setEntityPaneOpen(false); }; - const handleResetToGroup = (group: GroupType) => { - // First clear the entire diagram - clearDiagram(); - // Then add the selected group - addGroupToDiagram(group); + const handleDiagramNameChange = (event: React.ChangeEvent) => { + setDiagramName(event.target.value); }; - // Use the clearDiagram function from the hook - // const clearDiagram function is already available from the context + const diagramTools: DiagramTool[] = [ + { + id: 'add-entity', + label: 'Add Entity', + icon: , + action: handleAddEntity + }, + ]; return ( -
- - setTab(newValue)} aria-label="Diagram view tabs" variant="fullWidth"> - - - Build - - } - sx={{ minWidth: 0, flex: 1, fontSize: '0.75rem' }} - /> - - - Settings - - } - sx={{ minWidth: 0, flex: 1, fontSize: '0.75rem' }} - /> - + + + + - - {/* Mobile Notice */} - {isMobile && ( - - - - - Mobile Mode - - - Some advanced features may have limited functionality on mobile devices. - For the best experience, use a desktop computer. - - - - )} - - {/* Data Section */} - - - - - - - - - + - {/* General Section */} - - - - - - - - - - + {tool.icon} + + + + ))} + - - - - Diagram Type - - - Choose between simple or detailed entity view - - - - - - - - - - - - Save & Load - - - Save your diagram or load an existing one - - - - - - - - - - - - Current Settings - - - - Diagram Type: {diagramType} - - - Entities in Diagram: {currentEntities.length} - - - - - - - - - Diagram Actions - - - Reset or clear your diagram - - - - - - - - + + Elements + - {/* Add Entity Pane */} - + - {/* Add Group Pane */} - + + - {/* Reset to Group Pane */} - -
+ + + Loaded Diagram: ({loadedDiagramSource}) + + + {loadedDiagramFilename} + + + ); } \ 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 ( + + + + Export Options + + + {isExportingToCloud ? 'Export diagram to Azure DevOps as PNG' : 'Download diagram as PNG'} + + + + + + + + + Customize your PNG export settings: + + + setIncludeGrid(e.target.checked)} + color="primary" + /> + } + label={ + + Include Grid Background + + Show grid lines in the exported image + + + } + /> + + + + Tip: Exporting without the grid produces a cleaner image with transparent background, perfect for presentations and documentation. + + + + + + + + + + + + + ); +}; 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 ( + + + + {showCloudDiagrams && ( + + + + )} + + {showCloudDiagrams ? 'Select from Azure DevOps' : 'Load Diagram'} + + + + + + + + + {!showCloudDiagrams ? ( + // Main options view + + {/* Load from Device Card */} + + + {/* Load from Azure DevOps Card */} + + + {/* Hidden file input */} + + + ) : ( + // Cloud diagrams list view + + {isLoadingList ? ( + + + + Loading diagrams from Azure DevOps... + + + ) : availableDiagrams.length === 0 ? ( + + + + No diagrams found in the repository + + + ) : ( + + {availableDiagrams.map((diagram) => ( + + onLoadFromCloud(diagram.path)} + disabled={isLoading} + sx={{ + borderRadius: 1, + mb: 0.5, + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + ))} + + )} + + )} + + + ); +}; \ 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 ( + + + + {/* Title */} + + Saving Diagram to Azure DevOps + + + Connected to {repositoryInfo} + + + {/* Animation Container */} + + {/* DMV Logo (Left) */} + + + + DataModel +
+ Viewer +
+
+ + {/* Data Flow Animation (Center) */} + + + + + + {/* Azure DevOps Logo (Right) */} + + + + Azure +
+ DevOps +
+
+
+ + + Your diagram is being securely saved to the repository + +
+
+
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagramview/panes/AddAttributePane.tsx b/Website/components/diagramview/panes/AddAttributePane.tsx deleted file mode 100644 index f68be18..0000000 --- a/Website/components/diagramview/panes/AddAttributePane.tsx +++ /dev/null @@ -1,164 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - DialogActions, - Button, - TextField, - Card, - CardContent, - Typography, - Tooltip -} from '@mui/material'; -import { AttributeType } from '@/lib/Types'; -import { AssignmentRounded, AttachmentRounded, AttachMoneyRounded, CalendarMonthRounded, ListRounded, NumbersRounded, RttRounded, SearchRounded, ToggleOffRounded } from '@mui/icons-material'; - -export interface AddAttributePaneProps { - isOpen: boolean; - onClose: () => void; - onAddAttribute: (attribute: AttributeType) => void; - entityName?: string; - availableAttributes: AttributeType[]; - visibleAttributes: AttributeType[]; -} - -const getAttributeIcon = (attributeType: string) => { - switch (attributeType) { - case 'StringAttribute': return RttRounded; - case 'IntegerAttribute': return NumbersRounded; - case 'DecimalAttribute': return AttachMoneyRounded; - case 'DateTimeAttribute': return CalendarMonthRounded; - case 'BooleanAttribute': return ToggleOffRounded; - case 'ChoiceAttribute': return ListRounded; - case 'LookupAttribute': return SearchRounded; - case 'FileAttribute': return AttachmentRounded; - case 'StatusAttribute': return AssignmentRounded; - default: return RttRounded; - } -}; - -const getAttributeTypeLabel = (attributeType: string) => { - switch (attributeType) { - case 'StringAttribute': return 'Text'; - case 'IntegerAttribute': return 'Number (Whole)'; - case 'DecimalAttribute': return 'Number (Decimal)'; - case 'DateTimeAttribute': return 'Date & Time'; - case 'BooleanAttribute': return 'Yes/No'; - case 'ChoiceAttribute': return 'Choice'; - case 'LookupAttribute': return 'Lookup'; - case 'FileAttribute': return 'File'; - case 'StatusAttribute': return 'Status'; - default: return attributeType.replace('Attribute', ''); - } -}; - -export const AddAttributePane: React.FC = ({ - isOpen, - onClose, - onAddAttribute, - entityName, - availableAttributes, - visibleAttributes -}) => { - const [searchQuery, setSearchQuery] = useState(''); - - // Filter out attributes that are already visible in the diagram - const visibleAttributeNames = visibleAttributes.map(attr => attr.SchemaName); - const addableAttributes = availableAttributes.filter(attr => - !visibleAttributeNames.includes(attr.SchemaName) && - attr.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const handleAddAttribute = (attribute: AttributeType) => { - onAddAttribute(attribute); - setSearchQuery(''); - onClose(); - }; - - return ( - - - Add Existing Attribute ({availableAttributes.length}) - - {entityName ? `Select an attribute from "${entityName}" to add to the diagram.` : 'Select an attribute to add to the diagram.'} - - -
- {/* Search */} -
- Search Attributes - ) => setSearchQuery(e.target.value)} - placeholder="Search by attribute name..." - /> -
- - {/* Available Attributes */} -
- Available Attributes ({addableAttributes.length}) -
-
- {addableAttributes.length === 0 ? ( -
- {searchQuery ? 'No attributes found matching your search.' : 'No attributes available to add.'} -
- ) : ( -
- {addableAttributes.map((attribute) => { - const AttributeIcon = getAttributeIcon(attribute.AttributeType); - const typeLabel = getAttributeTypeLabel(attribute.AttributeType); - - return ( - handleAddAttribute(attribute)} - sx={{ cursor: 'pointer', '&:hover': { backgroundColor: 'action.hover' } }} - > - -
-
- -
-
-
- {attribute.DisplayName} -
-
- {typeLabel} • {attribute.SchemaName} -
-
- {attribute.Description && ( - -
- ? -
-
- )} -
-
-
- ); - })} -
- )} -
-
-
-
- - - - -
-
- ); -}; diff --git a/Website/components/diagramview/panes/AddEntityPane.tsx b/Website/components/diagramview/panes/AddEntityPane.tsx deleted file mode 100644 index 1ebdaca..0000000 --- a/Website/components/diagramview/panes/AddEntityPane.tsx +++ /dev/null @@ -1,259 +0,0 @@ -'use client'; - -import React, { useState, useMemo } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Checkbox, - Typography, - FormControlLabel -} from '@mui/material'; -import { Groups } from '@/generated/Data'; -import { EntityType, GroupType, AttributeType } from '@/lib/Types'; -import { useAttributeSelection } from '@/hooks/useAttributeSelection'; -import { AttributeSelectionPanel } from './AttributeSelectionPanel'; -import { SearchRounded } from '@mui/icons-material'; - -export interface AddEntityPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onAddEntity: (entity: EntityType, selectedAttributes?: string[]) => void; - currentEntities: EntityType[]; -} - -export const AddEntityPane: React.FC = ({ - isOpen, - onOpenChange, - onAddEntity, - currentEntities -}) => { - const [searchTerm, setSearchTerm] = useState(''); - const [selectedEntity, setSelectedEntity] = useState(null); - const [isAttributeSettingsExpanded, setIsAttributeSettingsExpanded] = useState(false); - - const { - attributeMode, - setAttributeMode, - customSelectedAttributes, - getSelectedAttributes, - initializeCustomAttributes, - toggleCustomAttribute, - resetCustomAttributes, - getAttributeModeDescription, - } = useAttributeSelection('custom-lookups'); - - // Filter groups and entities based on search term - const filteredData = useMemo(() => { - if (!searchTerm.trim()) { - return Groups; - } - - const lowerSearchTerm = searchTerm.toLowerCase(); - return Groups.map(group => ({ - ...group, - Entities: group.Entities.filter(entity => - entity.DisplayName.toLowerCase().includes(lowerSearchTerm) || - entity.SchemaName.toLowerCase().includes(lowerSearchTerm) || - group.Name.toLowerCase().includes(lowerSearchTerm) - ) - })).filter(group => - group.Name.toLowerCase().includes(lowerSearchTerm) || - group.Entities.length > 0 - ); - }, [searchTerm]); - - const handleAddEntity = (entity: EntityType) => { - const selectedAttributes = getSelectedAttributes(entity); - onAddEntity(entity, selectedAttributes); - onOpenChange(false); - setSelectedEntity(null); - resetCustomAttributes(); - }; - - const handleEntityClick = (entity: EntityType) => { - if (attributeMode === 'custom') { - setSelectedEntity(entity); - initializeCustomAttributes(entity); - } else { - handleAddEntity(entity); - } - }; - - const handleCustomAttributeToggle = (attributeSchemaName: string, checked: boolean) => { - toggleCustomAttribute(attributeSchemaName, checked); - }; - - return ( - onOpenChange(false)} maxWidth="md" fullWidth> - - Add Entity to Diagram -
- {/* Attribute Selection Options */} - - - {/* Search Input */} -
- - ) => setSearchTerm(e.target.value)} - sx={{ pl: '40px' }} - InputProps={{ style: { paddingLeft: '40px' } }} - /> -
- - {/* Groups and Entities List */} - {!selectedEntity ? ( -
- {filteredData.map((group: GroupType) => ( -
- - {group.Name} - -
- {group.Entities.map((entity: EntityType) => { - const isAlreadyInDiagram = currentEntities.some(e => e.SchemaName === entity.SchemaName); - return ( - - ); - })} -
-
- ))} - {filteredData.length === 0 && ( -
- No entities found matching your search. -
- )} -
- ) : ( - /* Custom Attribute Selection View */ -
-
-
- Configure {selectedEntity.DisplayName} - Select attributes to include -
- -
- -
- {selectedEntity.Attributes.map((attribute: AttributeType) => { - const isChecked = customSelectedAttributes.includes(attribute.SchemaName); - const isPrimaryKey = attribute.IsPrimaryId; - - return ( -
- ) => - handleCustomAttributeToggle(attribute.SchemaName, e.target.checked) - } - /> - } - label="" - /> -
-
- {attribute.DisplayName} - {isPrimaryKey && ( - - Primary Key - - )} - {attribute.AttributeType === "LookupAttribute" && ( - - Lookup - - )} -
-

{attribute.SchemaName}

- {attribute.Description && ( -

{attribute.Description}

- )} -
-
- ); - })} -
- -
- -
-
- )} -
-
-
- ); -}; diff --git a/Website/components/diagramview/panes/AddGroupPane.tsx b/Website/components/diagramview/panes/AddGroupPane.tsx deleted file mode 100644 index 872b743..0000000 --- a/Website/components/diagramview/panes/AddGroupPane.tsx +++ /dev/null @@ -1,187 +0,0 @@ -'use client'; - -import React, { useState, useMemo } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Typography -} from '@mui/material'; -import { Groups } from '@/generated/Data'; -import { EntityType, GroupType } from '@/lib/Types'; -import { useAttributeSelection } from '@/hooks/useAttributeSelection'; -import { AttributeSelectionPanel } from './AttributeSelectionPanel'; -import { FolderRounded, SearchRounded } from '@mui/icons-material'; - -export interface AddGroupPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onAddGroup: (group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => void; - currentEntities: EntityType[]; -} - -export const AddGroupPane: React.FC = ({ - isOpen, - onOpenChange, - onAddGroup, - currentEntities -}) => { - const [searchTerm, setSearchTerm] = useState(''); - const [isAttributeSettingsExpanded, setIsAttributeSettingsExpanded] = useState(false); - - const { - attributeMode, - setAttributeMode, - getSelectedAttributes, - getAttributeModeDescription, - } = useAttributeSelection('custom-lookups'); - - // Filter groups based on search term - const filteredGroups = useMemo(() => { - if (!searchTerm.trim()) { - return Groups; - } - - const lowerSearchTerm = searchTerm.toLowerCase(); - return Groups.filter(group => - group.Name.toLowerCase().includes(lowerSearchTerm) - ); - }, [searchTerm]); - - const handleAddGroup = (group: GroupType) => { - // Create attribute selection map for all entities in the group - const selectedAttributes: { [entitySchemaName: string]: string[] } = {}; - - group.Entities.forEach(entity => { - selectedAttributes[entity.SchemaName] = getSelectedAttributes(entity); - }); - - onAddGroup(group, selectedAttributes); - onOpenChange(false); - }; - - // Calculate how many entities from each group are already in the diagram - const getGroupStatus = (group: GroupType) => { - const entitiesInDiagram = group.Entities.filter(entity => - currentEntities.some(e => e.SchemaName === entity.SchemaName) - ).length; - const totalEntities = group.Entities.length; - return { entitiesInDiagram, totalEntities }; - }; - - return ( - onOpenChange(false)} maxWidth="md" fullWidth> - - Add Group to Diagram -
- {/* Attribute Selection Options */} - - - {/* Search Input */} -
- - ) => setSearchTerm(e.target.value)} - slotProps={{ input: { style: { paddingLeft: '40px' } } }} - /> -
- - {/* Groups List */} -
- {filteredGroups.map((group: GroupType) => { - const { entitiesInDiagram, totalEntities } = getGroupStatus(group); - const isFullyInDiagram = entitiesInDiagram === totalEntities && totalEntities > 0; - const isPartiallyInDiagram = entitiesInDiagram > 0 && entitiesInDiagram < totalEntities; - - return ( -
-
-
- -
- {group.Name} - - {group.Entities.length} entities - -
-
-
- - {entitiesInDiagram}/{totalEntities} entities - - {isFullyInDiagram && ( - - All in Diagram - - )} - {isPartiallyInDiagram && ( - - Partially Added - - )} -
-
- -
- {group.Entities.slice(0, 5).map((entity: EntityType) => { - const isInDiagram = currentEntities.some(e => e.SchemaName === entity.SchemaName); - return ( - - {entity.DisplayName} - - ); - })} - {group.Entities.length > 5 && ( - - +{group.Entities.length - 5} more - - )} -
- - -
- ); - })} - {filteredGroups.length === 0 && ( -
- No groups found matching your search. -
- )} -
-
-
-
- ); -}; diff --git a/Website/components/diagramview/panes/AttributeSelectionPanel.tsx b/Website/components/diagramview/panes/AttributeSelectionPanel.tsx deleted file mode 100644 index 7b5a150..0000000 --- a/Website/components/diagramview/panes/AttributeSelectionPanel.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import React from 'react'; -import { - Button, - Collapse, - RadioGroup, - FormControlLabel, - Radio, - Typography, - Box -} from '@mui/material'; -import { AttributeSelectionMode } from '@/hooks/useAttributeSelection'; -import { ChevronRightRounded, ExpandRounded, SettingsRounded } from '@mui/icons-material'; - -export interface AttributeSelectionPanelProps { - attributeMode: AttributeSelectionMode; - setAttributeMode: (mode: AttributeSelectionMode) => void; - isExpanded: boolean; - setIsExpanded: (expanded: boolean) => void; - getAttributeModeDescription: (mode: AttributeSelectionMode) => string; -} - -export const AttributeSelectionPanel: React.FC = ({ - attributeMode, - setAttributeMode, - isExpanded, - setIsExpanded, - getAttributeModeDescription -}) => { - return ( - - - - - - - Default attributes to include: - - ) => setAttributeMode(e.target.value as AttributeSelectionMode)} - > - } - label={ - - {getAttributeModeDescription('minimal')} - - } - /> - } - label={ - - {getAttributeModeDescription('custom-lookups')} - - } - /> - } - label={ - - {getAttributeModeDescription('all-lookups')} - - } - /> - } - label={ - - {getAttributeModeDescription('custom')} - - } - /> - - - - - ); -}; diff --git a/Website/components/diagramview/panes/EntityActionsPane.tsx b/Website/components/diagramview/panes/EntityActionsPane.tsx deleted file mode 100644 index c01f8ad..0000000 --- a/Website/components/diagramview/panes/EntityActionsPane.tsx +++ /dev/null @@ -1,319 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Collapse, - Box, - Typography, - Card, - CardContent, - Tooltip -} from '@mui/material'; -import { EntityType, AttributeType } from '@/lib/Types'; -import { RttRounded, NumbersRounded, AttachMoneyRounded, CalendarMonthRounded, ToggleOffRounded, ListRounded, SearchRounded, AttachmentRounded, AssignmentRounded, ChevronRight, AddRounded, ExpandRounded, DeleteRounded } from '@mui/icons-material'; - -export interface EntityActionsPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - selectedEntity: EntityType | null; - onDeleteEntity: () => void; - onAddAttribute?: (attribute: AttributeType) => void; - onRemoveAttribute?: (attribute: AttributeType) => void; - availableAttributes?: AttributeType[]; - visibleAttributes?: AttributeType[]; -} - -const getAttributeIcon = (attributeType: string) => { - switch (attributeType) { - case 'StringAttribute': return RttRounded; - case 'IntegerAttribute': return NumbersRounded; - case 'DecimalAttribute': return AttachMoneyRounded; - case 'DateTimeAttribute': return CalendarMonthRounded; - case 'BooleanAttribute': return ToggleOffRounded; - case 'ChoiceAttribute': return ListRounded; - case 'LookupAttribute': return SearchRounded; - case 'FileAttribute': return AttachmentRounded; - case 'StatusAttribute': return AssignmentRounded; - default: return RttRounded; - } -}; - -const getAttributeTypeLabel = (attributeType: string) => { - switch (attributeType) { - case 'StringAttribute': return 'Text'; - case 'IntegerAttribute': return 'Number (Whole)'; - case 'DecimalAttribute': return 'Number (Decimal)'; - case 'DateTimeAttribute': return 'Date & Time'; - case 'BooleanAttribute': return 'Yes/No'; - case 'ChoiceAttribute': return 'Choice'; - case 'LookupAttribute': return 'Lookup'; - case 'FileAttribute': return 'File'; - case 'StatusAttribute': return 'Status'; - default: return attributeType.replace('Attribute', ''); - } -}; - -export const EntityActionsPane: React.FC = ({ - isOpen, - onOpenChange, - selectedEntity, - onDeleteEntity, - onAddAttribute, - onRemoveAttribute, - availableAttributes = [], - visibleAttributes = [] -}) => { - const [searchQuery, setSearchQuery] = useState(''); - const [isAttributesExpanded, setIsAttributesExpanded] = useState(false); - const [isRemoveAttributesExpanded, setIsRemoveAttributesExpanded] = useState(false); - - // Filter out attributes that are already visible in the diagram - const visibleAttributeNames = visibleAttributes.map(attr => attr.SchemaName); - const addableAttributes = availableAttributes.filter(attr => - !visibleAttributeNames.includes(attr.SchemaName) && - attr.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const handleAddAttribute = (attribute: AttributeType) => { - if (onAddAttribute) { - onAddAttribute(attribute); - setSearchQuery(''); - setIsAttributesExpanded(false); - } - }; - - const handleRemoveAttribute = (attribute: AttributeType) => { - if (onRemoveAttribute) { - onRemoveAttribute(attribute); - } - }; - - // Filter removable attributes (exclude primary key) - const removableAttributes = visibleAttributes.filter(attr => - !attr.IsPrimaryId // Don't allow removing primary key - all other visible attributes can be removed - ); - - return ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - {selectedEntity && ( - <> - {selectedEntity.DisplayName} - -
-
- - {selectedEntity.SchemaName} - - {selectedEntity.Description && ( - - {selectedEntity.Description} - - )} -
- -
-
- Actions - - {/* Add Attribute Section */} - {onAddAttribute && availableAttributes.length > 0 && ( - - - - - - {/* Search */} - ) => setSearchQuery(e.target.value)} - placeholder="Search attributes..." - sx={{ mb: 2 }} - /> - - {/* Available Attributes */} - - {addableAttributes.length === 0 ? ( - - - {searchQuery ? 'No attributes found.' : 'No attributes available.'} - - - ) : ( - - {addableAttributes.map((attribute) => { - const AttributeIcon = getAttributeIcon(attribute.AttributeType); - const typeLabel = getAttributeTypeLabel(attribute.AttributeType); - - return ( - handleAddAttribute(attribute)} - > - - - - - - {attribute.DisplayName} - - - {typeLabel} - - - {attribute.Description && ( - - - ? - - - )} - - - - ); - })} - - )} - - - - - )} - - {/* Remove Attribute Section */} - {onRemoveAttribute && removableAttributes.length > 0 && ( - - - - - - - {removableAttributes.map((attribute) => { - const AttributeIcon = getAttributeIcon(attribute.AttributeType); - const typeLabel = getAttributeTypeLabel(attribute.AttributeType); - - return ( - handleRemoveAttribute(attribute)} - > - - - - - - {attribute.DisplayName} - - - {typeLabel} - - - - - - - ); - })} - - - Note: Primary key cannot be removed. - - - - - )} - - -
-
- -
-
- Entity Information -
- - Attributes: {selectedEntity.Attributes.length} - - - Relationships: {selectedEntity.Relationships?.length || 0} - - - Is Activity: {selectedEntity.IsActivity ? 'Yes' : 'No'} - - - Audit Enabled: {selectedEntity.IsAuditEnabled ? 'Yes' : 'No'} - -
-
-
-
- - )} -
-
- ); -}; diff --git a/Website/components/diagramview/panes/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 ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - Link Properties - - Customize the appearance and label of the relationship link. - - - - {/* Label Section */} - - Link Label - ) => setLabel(e.target.value)} - /> - - - - - - Optional text to display on the link - - - - - - {/* Color Section */} - - Link Color - - {PRESET_COLORS.borders.map((presetColor) => ( - - ))} - - - - ) => handleColorChange(e.target.value)} - sx={{ width: 48, '& .MuiInputBase-input': { p: 0.5 } }} - size="small" - /> - ) => handleColorChange(e.target.value)} - placeholder="#3b82f6" - /> - - - - - - {/* Line Style Section */} - - - Line Style - - - - - {/* Stroke Width Section */} - - - Line Thickness - - - - - - - ); -}; diff --git a/Website/components/diagramview/panes/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 ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - Reset Diagram to Group - - Choose a group to reset the diagram and show only entities from that group. - This will clear the current diagram and add all entities from the selected group. - - - - - Select Group - - - - - - Warning - - - This will clear all current elements from your diagram and replace them with entities from the selected group. - - - - - - - - - - - ); -}; diff --git a/Website/components/diagramview/panes/SquarePropertiesPane.tsx b/Website/components/diagramview/panes/SquarePropertiesPane.tsx deleted file mode 100644 index d3c01f8..0000000 --- a/Website/components/diagramview/panes/SquarePropertiesPane.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Typography, - Box, - Divider -} from '@mui/material'; -import { SquareElement, SquareElementData } from '../elements/SquareElement'; -import { PRESET_COLORS } from '../shared/DiagramConstants'; -import { DeleteRounded, SquareRounded } from '@mui/icons-material'; - -export interface SquarePropertiesPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - selectedSquare: SquareElement | null; - onDeleteSquare?: () => void; -} - -export const SquarePropertiesPane: React.FC = ({ - isOpen, - onOpenChange, - selectedSquare, - onDeleteSquare -}) => { - const [squareData, setSquareData] = useState({ - borderColor: PRESET_COLORS.borders[0].value, - fillColor: PRESET_COLORS.fills[0].value, - borderWidth: 2, - borderType: 'dashed', - opacity: 0.7 - }); - - // Update local state when selected square changes - useEffect(() => { - if (selectedSquare) { - const data = selectedSquare.getSquareData(); - setSquareData({ - borderColor: data.borderColor || PRESET_COLORS.borders[0].value, - fillColor: data.fillColor || PRESET_COLORS.fills[0].value, - borderWidth: data.borderWidth || 2, - borderType: data.borderType || 'dashed', - opacity: data.opacity || 0.7 - }); - } - }, [selectedSquare]); - - const handleDataChange = (key: keyof SquareElementData, value: string | number) => { - const newData = { ...squareData, [key]: value }; - setSquareData(newData); - - // Apply changes immediately to the square - if (selectedSquare) { - selectedSquare.updateSquareData(newData); - } - }; - - const handlePresetFillColor = (color: string) => { - handleDataChange('fillColor', color); - }; - - const handlePresetBorderColor = (color: string) => { - handleDataChange('borderColor', color); - }; - - const handleDeleteSquare = () => { - if (selectedSquare && onDeleteSquare) { - onDeleteSquare(); - onOpenChange(false); // Close the panel after deletion - } - }; - - if (!selectedSquare) { - return null; - } - - return ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - - Square Properties - - - - {/* Fill Color Section */} - - - Fill Color - - - {PRESET_COLORS.fills.map((color) => ( - - - - - - ); -}; diff --git a/Website/components/diagramview/panes/TextPropertiesPane.tsx b/Website/components/diagramview/panes/TextPropertiesPane.tsx deleted file mode 100644 index 5ba731e..0000000 --- a/Website/components/diagramview/panes/TextPropertiesPane.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - Button, - TextField, - Typography, - Box, - Divider -} from '@mui/material'; -import { TextElement, TextElementData } from '../elements/TextElement'; -import { DeleteRounded, RttRounded } from '@mui/icons-material'; - -export interface TextPropertiesPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - selectedText: TextElement | null; - onDeleteText?: () => void; -} - -const FONT_SIZES = [ - { name: 'Small', value: 12 }, - { name: 'Normal', value: 14 }, - { name: 'Medium', value: 16 }, - { name: 'Large', value: 20 }, - { name: 'Extra Large', value: 24 } -]; - -const FONT_FAMILIES = [ - { name: 'Roboto', value: 'Roboto, sans-serif' }, - { name: 'System Font', value: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' }, - { name: 'Arial', value: 'Arial, sans-serif' }, - { name: 'Helvetica', value: 'Helvetica, Arial, sans-serif' }, - { name: 'Times', value: 'Times, "Times New Roman", serif' }, - { name: 'Courier', value: 'Courier, "Courier New", monospace' } -]; - -export const TextPropertiesPane: React.FC = ({ - isOpen, - onOpenChange, - selectedText, - onDeleteText -}) => { - const [textData, setTextData] = useState({ - text: 'Text Element', - fontSize: 14, - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - color: '#000000', - backgroundColor: 'transparent', - padding: 8, - borderRadius: 4, - textAlign: 'left' - }); - - // Update local state when selected text changes - useEffect(() => { - if (selectedText) { - const data = selectedText.getTextData(); - setTextData({ - text: data.text || 'Text Element', - fontSize: data.fontSize || 14, - fontFamily: data.fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - color: data.color || '#000000', - backgroundColor: data.backgroundColor || 'transparent', - padding: data.padding || 8, - borderRadius: data.borderRadius || 4, - textAlign: data.textAlign || 'left' - }); - } - }, [selectedText]); - - // Apply changes immediately when any property changes - useEffect(() => { - if (selectedText) { - selectedText.updateTextData(textData); - } - }, [textData, selectedText]); - - const handleDataChange = (key: keyof TextElementData, value: string | number) => { - setTextData(prev => ({ ...prev, [key]: value })); - }; - - const handleDeleteText = () => { - if (selectedText && onDeleteText) { - onDeleteText(); - onOpenChange(false); - } - }; - - if (!selectedText) { - return null; - } - - return ( - onOpenChange(false)} maxWidth="sm" fullWidth> - - - Text Properties - - - - {/* Text Content */} - - - Text Content - - ) => handleDataChange('text', e.target.value)} - fullWidth - size="small" - /> - - - - - {/* Typography */} - - - Typography - - - {/* Font Family */} - - - Font Family - - ) => handleDataChange('fontFamily', e.target.value)} - size="small" - fullWidth - SelectProps={{ native: true }} - > - {FONT_FAMILIES.map((font) => ( - - ))} - - - - {/* Font Size */} - - - Font Size - - ) => handleDataChange('fontSize', parseInt(e.target.value))} - size="small" - fullWidth - SelectProps={{ native: true }} - > - {FONT_SIZES.map((size) => ( - - ))} - - - - {/* Text Alignment */} - - - Text Alignment - - ) => handleDataChange('textAlign', e.target.value as 'left' | 'center' | 'right')} - size="small" - fullWidth - SelectProps={{ native: true }} - > - - - - - - - - - - {/* Colors */} - - - Colors - - - {/* Text Color */} - - - Text Color - - - ) => handleDataChange('color', e.target.value)} - sx={{ width: 48, '& .MuiInputBase-input': { padding: 0.5, height: 32 } }} - size="small" - /> - ) => handleDataChange('color', e.target.value)} - placeholder="#000000" - sx={{ flex: 1 }} - size="small" - /> - - - - {/* Background Color */} - - - Background Color - - - ) => handleDataChange('backgroundColor', e.target.value)} - sx={{ width: 48, '& .MuiInputBase-input': { padding: 0.5, height: 32 } }} - size="small" - /> - ) => handleDataChange('backgroundColor', e.target.value)} - placeholder="transparent" - sx={{ flex: 1 }} - size="small" - /> - - - - - - - {/* Layout */} - - - Layout - - - {/* Padding */} - - - Padding - - ) => handleDataChange('padding', parseInt(e.target.value) || 0)} - size="small" - fullWidth - /> - - - {/* Border Radius */} - - - Border Radius - - ) => handleDataChange('borderRadius', parseInt(e.target.value) || 0)} - size="small" - fullWidth - /> - - - - - - {/* Delete Section */} - {onDeleteText && ( - - - Danger Zone - - - - )} - - - - ); -}; diff --git a/Website/components/diagramview/panes/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 ( + + + Edit Entity + + + ); +}; \ 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 && ( + + )} + + {excludedRelationships.length > 0 && ( + <> + + + Excluded Relationships + + + {excludedRelationships.length} hidden relationship{excludedRelationships.length !== 1 ? 's' : ''} + + + {excludedRelationships.map((link, index) => ( + + + + {link.sourceSchemaName} - {link.targetSchemaName} + + + + + + + + ))} + + + )} + + + + 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 ( + <> + + + + {menuItems.map((item) => ( + handleMenuItemClick(item.action)} + disabled={item.disabled} + divider={item.dividerAfter} + sx={{ borderWidth: 2 }} + > + {item.icon && ( + + {item.icon} + + )} + {item.label} + + ))} + + + ); +}; \ No newline at end of file diff --git a/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx b/Website/components/diagramview/smaller-components/HeaderMenuItem.tsx new file mode 100644 index 0000000..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>(relationships.reduce((map, rel, index) => { + map.set(index, rel.isIncluded); + return map; + }, new Map())); + const [label, setLabel] = useState(''); + + // Load current label from link when component mounts or linkId changes + useEffect(() => { + if (!linkId) return; + + const graph = getGraph(); + if (!graph) return; + + const link = graph.getLinks().find(l => l.id === linkId); + if (link) { + const labels = link.labels(); + const currentLabel = labels.length > 0 ? labels[0].attrs?.label?.text || '' : ''; + setLabel(currentLabel); + } + }, [linkId, getGraph]); + + const handleToggleInclude = (index: number, currentValue: boolean | undefined) => { + if (!linkId) return; + toggleRelationshipLink(linkId, relationships[index].RelationshipSchemaName, !currentValue); + setIsToggled((prev) => new Map(prev).set(index, !currentValue)); + }; + + const handleLabelChange = (event: React.ChangeEvent) => { + const newLabel = event.target.value; + setLabel(newLabel); + if (linkId) { + updateRelationshipLinkLabel(linkId, newLabel); + } + }; + + return ( + + + Relationship Properties + + + + + + + + + + {relationships.length} relationship{relationships.length !== 1 ? 's' : ''} + + + + {relationships.map((rel, index) => { + + return ( + + + + + {rel.sourceEntityDisplayName} + {rel.sourceEntitySchemaName} + + {rel.RelationshipType} + + {rel.targetEntityDisplayName} + {rel.targetEntitySchemaName} + + + + + {/* Additional relationship details */} + + {rel.RelationshipSchemaName && ( + + Schema: {rel.RelationshipSchemaName} + + )} + + + {rel.IsManyToMany && ( + + )} + {rel.isIncluded === undefined && ( + + )} + + handleToggleInclude(index, isToggled.get(index))} + /> + } + label={{isToggled.get(index) ? 'Included' : 'Excluded'}} + className='ml-auto' + /> + + + + ); + })} + + + ) +} + +export default RelationshipProperties; \ No newline at end of file diff --git a/Website/components/diagramview/smaller-components/SelectionProperties.tsx b/Website/components/diagramview/smaller-components/SelectionProperties.tsx new file mode 100644 index 0000000..46e72ca --- /dev/null +++ b/Website/components/diagramview/smaller-components/SelectionProperties.tsx @@ -0,0 +1,43 @@ +import { useDiagramView } from "@/contexts/DiagramViewContext"; +import { EntityType } from "@/lib/Types"; +import { Box, Button, Divider, Typography } from "@mui/material"; + +interface ISelectionPropertiesProps { + selectedEntities: EntityType[]; +} + +export const SelectionProperties = ({ selectedEntities }: ISelectionPropertiesProps) => { + const { applySmartLayout, getSelectedEntities } = useDiagramView(); + + // Get the current selected entities from the context + const currentlySelectedEntities = getSelectedEntities(); + + // Use the current selection if available, otherwise fall back to the prop + const entitiesToShow = currentlySelectedEntities.length > 0 ? currentlySelectedEntities : selectedEntities; + + const handleSmartLayout = () => { + if (entitiesToShow.length > 0) { + applySmartLayout(entitiesToShow); + } + }; + + return ( + + + {entitiesToShow.length > 0 + ? entitiesToShow.map(e => e.SchemaName).join(", ") + : "No Entities Selected" + } + + + + + ); +}; \ No newline at end of file diff --git a/Website/components/homeview/HomeView.tsx b/Website/components/homeview/HomeView.tsx index 052f6a5..b7d47d0 100644 --- a/Website/components/homeview/HomeView.tsx +++ b/Website/components/homeview/HomeView.tsx @@ -23,6 +23,14 @@ export const HomeView = ({ }: IHomeViewProps) => { // Carousel data const carouselItems: CarouselItem[] = [ + { + image: '/documentation.jpg', + title: 'Connect to your Azure DevOps!', + text: 'The diagram tool is the first to take advantage of the new integration. Save and load your diagrams directly from your Azure DevOps repository to keep version control on your diagrams. Check out the documentation to get started.', + type: '(v2.2.0) Feature', + actionlabel: 'Go to Diagrams', + action: () => router.push('/diagram') + }, { image: '/insights.jpg', title: 'Insights are here!', @@ -44,14 +52,6 @@ export const HomeView = ({ }: IHomeViewProps) => { title: 'Data Model Viewer 2.0.0!', text: "The UI has been refreshed for an even cleaner, more modern look with enhanced functionality. And we've upgraded the tech stack to ensure easier maintainability.", type: '(v2.0.0) Announcement' - }, - { - image: '/documentation.jpg', - title: 'Home WIKI ADO Page', - text: 'Display your own wiki page from your ADO instance. Use it, to give your organisation a special introduction to DMV. Now also supports images!', - type: '(v1.4.1) Feature', - actionlabel: 'Read how', - action: () => window.open("https://github.com/delegateas/DataModelViewer", '_blank') } ]; diff --git a/Website/components/shared/Header.tsx b/Website/components/shared/Header.tsx index 5001ce9..c72417c 100644 --- a/Website/components/shared/Header.tsx +++ b/Website/components/shared/Header.tsx @@ -1,7 +1,7 @@ import { useLoading } from '@/hooks/useLoading'; import { useAuth } from '@/contexts/AuthContext'; import { useSettings } from '@/contexts/SettingsContext'; -import { useRouter } from 'next/navigation'; +import { useRouter, usePathname } from 'next/navigation'; import { AppBar, Toolbar, Box, LinearProgress, Button, Stack } from '@mui/material'; import SettingsPane from './elements/SettingsPane'; import { useIsMobile } from '@/hooks/use-mobile'; @@ -18,12 +18,15 @@ const Header = ({ }: HeaderProps) => { const { isOpen: sidebarOpen, expand } = useSidebar(); const isMobile = useIsMobile(); const router = useRouter(); + const pathname = usePathname(); const { isAuthenticating, isRedirecting, } = useLoading(); + const isDiagramRoute = pathname === '/diagram'; + const handleSettingsClick = () => { setSettingsOpen(true); }; @@ -53,7 +56,7 @@ const Header = ({ }: HeaderProps) => { position="sticky" color="transparent" elevation={0} - className="w-full top-0 z-0 border-b h-header max-h-header" + className={`w-full top-0 z-0 h-header max-h-header ${isDiagramRoute ? 'border-b border-dashed' : 'border-b'}`} sx={{ bgcolor: 'background.paper', borderColor: 'border.main' }} > diff --git a/Website/components/shared/Layout.tsx b/Website/components/shared/Layout.tsx index a7b2f75..e7db248 100644 --- a/Website/components/shared/Layout.tsx +++ b/Website/components/shared/Layout.tsx @@ -12,9 +12,10 @@ interface LayoutProps { children: ReactNode; className?: string; showSidebarContent?: boolean; + ignoreMargins?: boolean; } -const Layout = ({ children }: LayoutProps) => { +const Layout = ({ children, ignoreMargins = false }: LayoutProps) => { const { isOpen: sidebarOpen, close } = useSidebar(); const isMobile = useIsMobile(); const { isAuthenticated } = useAuth(); @@ -50,9 +51,16 @@ const Layout = ({ children }: LayoutProps) => {
- - {children} - + { + ignoreMargins ? + + {children} + + : + + {children} + + } diff --git a/Website/components/shared/Sidebar.tsx b/Website/components/shared/Sidebar.tsx index 4c3b4fe..04298cf 100644 --- a/Website/components/shared/Sidebar.tsx +++ b/Website/components/shared/Sidebar.tsx @@ -41,7 +41,6 @@ const Sidebar = ({ }: SidebarProps) => { href: '/insights', icon: InsightsIcon, active: pathname === '/insights', - new: true, }, { label: 'Metadata', @@ -54,6 +53,7 @@ const Sidebar = ({ }: SidebarProps) => { href: '/diagram', icon: DiagramIcon, active: pathname === '/diagram', + new: true, }, { label: 'Processes', @@ -191,7 +191,7 @@ const Sidebar = ({ }: SidebarProps) => { - + ))} {isOpen && element != null && ( diff --git a/Website/components/shared/elements/ClickableCard.tsx b/Website/components/shared/elements/ClickableCard.tsx new file mode 100644 index 0000000..36d5c20 --- /dev/null +++ b/Website/components/shared/elements/ClickableCard.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React from 'react'; +import { + Card, + CardContent, + CardActionArea, + Box, + Typography +} from '@mui/material'; + +interface ClickableCardProps { + title: string; + description: string; + icon: React.ReactNode; + onClick: () => void; + disabled?: boolean; + color?: string; +} + +export const ClickableCard = ({ + title, + description, + icon, + onClick, + disabled = false, + color = '#1976d2' // Default primary blue +}: ClickableCardProps) => { + return ( + + + + + + {icon} + + + + + + {title} + + + {description} + + + + + + ); +}; \ No newline at end of file diff --git a/Website/components/shared/elements/EntityGroupAccordion.tsx b/Website/components/shared/elements/EntityGroupAccordion.tsx new file mode 100644 index 0000000..5c4c01c --- /dev/null +++ b/Website/components/shared/elements/EntityGroupAccordion.tsx @@ -0,0 +1,219 @@ +'use client'; + +import React, { useCallback } from 'react'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Box, + Typography, + Button, + CircularProgress +} from '@mui/material'; +import { + ExpandMore, + OpenInNewRounded, + ExtensionRounded +} from '@mui/icons-material'; +import { useTheme, alpha } from '@mui/material/styles'; +import { cn } from "@/lib/utils"; +import { GroupType, EntityType } from "@/lib/Types"; + +interface EntityGroupAccordionProps { + group: GroupType; + isExpanded: boolean; + onToggle: (groupName: string) => void; + onEntityClick: (entity: EntityType, groupName: string) => void; + onGroupClick?: (group: GroupType) => void; + currentSection?: string | null; + currentGroup?: string | null; + loadingSection?: string | null; + searchTerm?: string; + highlightText?: (text: string, searchTerm: string) => React.ReactNode; + isEntityMatch?: (entity: EntityType) => boolean; + showGroupClickIcon?: boolean; + isDisabled?: (entity: EntityType) => boolean; +} + +export const EntityGroupAccordion = ({ + group, + isExpanded, + onToggle, + onEntityClick, + onGroupClick, + currentSection, + currentGroup, + loadingSection, + searchTerm = '', + highlightText, + isEntityMatch, + showGroupClickIcon = false, + isDisabled +}: EntityGroupAccordionProps) => { + const theme = useTheme(); + const isCurrentGroup = currentGroup?.toLowerCase() === group.Name.toLowerCase(); + + const handleGroupClick = useCallback(() => { + onToggle(group.Name); + }, [onToggle, group.Name]); + + const handleGroupIconClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (onGroupClick) { + onGroupClick(group); + } + }, [onGroupClick, group]); + + const handleEntityButtonClick = useCallback((entity: EntityType) => { + onEntityClick(entity, group.Name); + }, [onEntityClick, group.Name]); + + return ( + + } + className={cn( + "p-2 duration-200 flex items-center rounded-md text-xs font-semibold text-sidebar-foreground/80 outline-none ring-sidebar-ring transition-all focus-visible:ring-2 cursor-pointer w-full min-w-0", + isCurrentGroup ? "font-semibold" : "hover:bg-sidebar-accent hover:text-sidebar-primary" + )} + sx={{ + backgroundColor: isExpanded ? alpha(theme.palette.primary.main, 0.1) : 'transparent', + padding: '4px', + minHeight: '32px !important', + '& .MuiAccordionSummary-content': { + margin: 0, + alignItems: 'center', + minWidth: 0, + overflow: 'hidden' + } + }} + > + + {group.Name} + + + {group.Entities.length} + + + {showGroupClickIcon && ( + + )} + + + + {group.Entities.map(entity => { + const isCurrentSection = currentSection?.toLowerCase() === entity.SchemaName.toLowerCase(); + const isMatch = isEntityMatch ? isEntityMatch(entity) : false; + const isLoading = loadingSection === entity.SchemaName; + const isCurrentDisabled = isDisabled && isDisabled(entity); + + // If searching and this entity doesn't match, don't render it + if (searchTerm.trim() && !isMatch) { + return null; + } + + return ( + + ); + })} + + + + ); +}; \ No newline at end of file diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index ffb5cbb..5fa747e 100644 --- a/Website/contexts/DatamodelDataContext.tsx +++ b/Website/contexts/DatamodelDataContext.tsx @@ -1,18 +1,23 @@ 'use client' import React, { createContext, useContext, useReducer, ReactNode } from "react"; -import { EntityType, GroupType, SolutionType, SolutionWarningType } from "@/lib/Types"; +import { EntityType, GroupType, RelationshipType, SolutionType, SolutionWarningType } from "@/lib/Types"; import { useSearchParams } from "next/navigation"; -interface DatamodelDataState { +interface DataModelAction { + getEntityDataBySchemaName: (schemaName: string) => EntityType | undefined; +} + +interface DatamodelDataState extends DataModelAction { groups: GroupType[]; + entityMap?: Map; warnings: SolutionWarningType[]; solutions: SolutionType[]; search: string; filtered: Array< - | { type: 'group'; group: GroupType } - | { type: 'entity'; group: GroupType; entity: EntityType } - >; + | { type: 'group'; group: GroupType } + | { type: 'entity'; group: GroupType; entity: EntityType } + >; } const initialState: DatamodelDataState = { @@ -20,16 +25,20 @@ const initialState: DatamodelDataState = { warnings: [], solutions: [], search: "", - filtered: [] + filtered: [], + + getEntityDataBySchemaName: () => { throw new Error("getEntityDataBySchemaName not implemented.") }, }; const DatamodelDataContext = createContext(initialState); -const DatamodelDataDispatchContext = createContext>(() => {}); +const DatamodelDataDispatchContext = createContext>(() => { }); const datamodelDataReducer = (state: DatamodelDataState, action: any): DatamodelDataState => { switch (action.type) { case "SET_GROUPS": return { ...state, groups: action.payload }; + case "SET_ENTITIES": + return { ...state, entityMap: action.payload }; case "SET_WARNINGS": return { ...state, warnings: action.payload }; case "SET_SEARCH": @@ -55,9 +64,10 @@ export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => dispatch({ type: "SET_SEARCH", payload: globalsearchParam || "" }); - const worker = new Worker(new URL("../components/datamodelview/dataLoaderWorker.js", import.meta.url)); + const worker = new Worker(new URL("../components/datamodelview/dataLoaderWorker.ts", import.meta.url)); worker.onmessage = (e) => { dispatch({ type: "SET_GROUPS", payload: e.data.groups || [] }); + dispatch({ type: "SET_ENTITIES", payload: e.data.entityMap || new Map() }); dispatch({ type: "SET_WARNINGS", payload: e.data.warnings || [] }); dispatch({ type: "SET_SOLUTIONS", payload: e.data.solutions || [] }); worker.terminate(); @@ -66,8 +76,12 @@ export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => return () => worker.terminate(); }, []); + const getEntityDataBySchemaName = (schemaName: string): EntityType | undefined => { + return state.entityMap?.get(schemaName); + } + return ( - + {children} diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index 1b0d4e0..4a1111f 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -1,33 +1,910 @@ -import React, { createContext, useContext, ReactNode } from 'react'; -import { useDiagram, DiagramState, DiagramActions } from '@/hooks/useDiagram'; +import { dia, shapes } from '@joint/core'; +import React, { createContext, useContext, ReactNode, useReducer, useEffect, useRef } from 'react'; +import { createEntity, EntityElement, EntityElementView } from '@/components/diagramview/diagram-elements/EntityElement'; +import EntitySelection, { SelectionElement } from '@/components/diagramview/diagram-elements/Selection'; +import { SmartLayout } from '@/components/diagramview/layout/SmartLayout'; +import { EntityType } from '@/lib/Types'; +import { AvoidRouter } from '@/components/diagramview/avoid-router/shared/avoidrouter'; +import { initializeRouter } from '@/components/diagramview/avoid-router/shared/initialization'; +import { createRelationshipLink, RelationshipLink, RelationshipLinkView, updateLinkMarkers } from '@/components/diagramview/diagram-elements/RelationshipLink'; +import { getAllRelationshipsBetween, linkExistsBetween } from '@/lib/diagram/relationship-helpers'; +import { RelationshipInformation } from '@/lib/diagram/models/relationship-information'; -interface DiagramViewContextType extends DiagramState, DiagramActions {} +export interface ExcludedLinkMetadata { + linkId: string; + sourceId: string; + targetId: string; + sourceSchemaName: string; + targetSchemaName: string; + relationshipInformationList: RelationshipInformation[]; + label?: any; // Store the full JointJS label object +} + +interface DiagramActions { + setZoom: (zoom: number) => void; + setIsPanning: (isPanning: boolean) => void; + setTranslate: (translate: { x: number; y: number }) => void; + addEntity: (entityData: EntityType, position?: { x: number; y: number }, label?: string) => void; + removeEntity: (entitySchemaName: string) => void; + getGraph: () => dia.Graph | null; + getPaper: () => dia.Paper | null; + applyZoomAndPan: (zoom: number, translate: { x: number; y: number }) => void; + setLoadedDiagram: (filename: string | null, source: 'cloud' | 'file' | null, filePath?: string | null) => void; + clearDiagram: () => void; + setDiagramName: (name: string) => void; + selectEntity: (entityId: string, ctrlClick?: boolean) => void; + clearSelection: () => void; + isEntityInDiagram: (entity: EntityType) => boolean; + applySmartLayout: (entities: EntityType[]) => void; + getSelectedEntities: () => EntityType[]; + toggleRelationshipLink: (linkId: string, relationshipSchemaName: string, include: boolean) => void; + restoreRelationshipLink: (sourceSchemaName: string, targetSchemaName: string) => void; + getExcludedLinks: () => Map; + updateRelationshipLinkLabel: (linkId: string, label: string) => void; +} + +export interface DiagramState extends DiagramActions { + canvas: React.MutableRefObject; + zoom: number; + isPanning: boolean; + translate: { x: number; y: number }; + loadedDiagramFilename: string | null; + loadedDiagramSource: 'cloud' | 'file' | null; + loadedDiagramFilePath: string | null; + hasLoadedDiagram: boolean; + diagramName: string; + selectedEntities: string[]; + entitiesInDiagram: Map; + excludedLinks: Map; +} + +const initialState: DiagramState = { + zoom: 1, + isPanning: false, + translate: { x: 0, y: 0 }, + canvas: React.createRef(), + loadedDiagramFilename: null, + loadedDiagramSource: null, + loadedDiagramFilePath: null, + hasLoadedDiagram: false, + diagramName: 'untitled', + selectedEntities: [], + entitiesInDiagram: new Map(), + excludedLinks: new Map(), + + setZoom: () => { throw new Error("setZoom not initialized yet!"); }, + setIsPanning: () => { throw new Error("setIsPanning not initialized yet!"); }, + setTranslate: () => { throw new Error("setTranslate not initialized yet!"); }, + addEntity: () => { throw new Error("addEntity not initialized yet!"); }, + removeEntity: () => { throw new Error("removeEntity not initialized yet!"); }, + getGraph: () => { throw new Error("getGraph not initialized yet!"); }, + getPaper: () => { throw new Error("getPaper not initialized yet!"); }, + applyZoomAndPan: () => { throw new Error("applyZoomAndPan not initialized yet!"); }, + setLoadedDiagram: () => { throw new Error("setLoadedDiagram not initialized yet!"); }, + clearDiagram: () => { throw new Error("clearDiagram not initialized yet!"); }, + setDiagramName: () => { throw new Error("setDiagramName not initialized yet!"); }, + selectEntity: () => { throw new Error("selectEntity not initialized yet!"); }, + clearSelection: () => { throw new Error("clearSelection not initialized yet!"); }, + isEntityInDiagram: () => { throw new Error("isEntityInDiagram not initialized yet!"); }, + applySmartLayout: () => { throw new Error("applySmartLayout not initialized yet!"); }, + getSelectedEntities: () => { throw new Error("getSelectedEntities not initialized yet!"); }, + toggleRelationshipLink: () => { throw new Error("toggleRelationshipLink not initialized yet!"); }, + restoreRelationshipLink: () => { throw new Error("restoreRelationshipLink not initialized yet!"); }, + getExcludedLinks: () => { throw new Error("getExcludedLinks not initialized yet!"); }, + updateRelationshipLinkLabel: () => { throw new Error("updateRelationshipLinkLabel not initialized yet!"); } +} + +type DiagramViewAction = + | { type: 'SET_ZOOM', payload: number } + | { type: 'SET_IS_PANNING', payload: boolean } + | { type: 'SET_TRANSLATE', payload: { x: number; y: number } } + | { type: 'SET_LOADED_DIAGRAM', payload: { filename: string | null; source: 'cloud' | 'file' | null; filePath?: string | null } } + | { type: 'CLEAR_DIAGRAM' } + | { type: 'SET_DIAGRAM_NAME', payload: string } + | { type: 'SELECT_ENTITY', payload: { entityId: string; multiSelect: boolean } } + | { type: 'CLEAR_SELECTION' } + | { type: 'SET_SELECTION', payload: string[] } + | { type: 'ADD_ENTITY_TO_DIAGRAM', payload: EntityType } + | { type: 'REMOVE_ENTITY_FROM_DIAGRAM', payload: string } + | { type: 'ADD_EXCLUDED_LINK', payload: ExcludedLinkMetadata } + | { type: 'REMOVE_EXCLUDED_LINK', payload: string }; + +const diagramViewReducer = (state: DiagramState, action: DiagramViewAction): DiagramState => { + switch (action.type) { + case 'SET_ZOOM': + return { ...state, zoom: action.payload } + case 'SET_IS_PANNING': + return { ...state, isPanning: action.payload } + case 'SET_TRANSLATE': + return { ...state, translate: action.payload } + case 'SET_LOADED_DIAGRAM': + return { + ...state, + loadedDiagramFilename: action.payload.filename, + loadedDiagramSource: action.payload.source, + loadedDiagramFilePath: action.payload.filePath || null, + hasLoadedDiagram: action.payload.filename !== null, + diagramName: action.payload.filename || 'untitled' + } + case 'CLEAR_DIAGRAM': + return { + ...state, + loadedDiagramFilename: null, + loadedDiagramSource: null, + loadedDiagramFilePath: null, + hasLoadedDiagram: false, + diagramName: 'untitled', + entitiesInDiagram: new Map(), + excludedLinks: new Map() + } + case 'SET_DIAGRAM_NAME': + return { ...state, diagramName: action.payload } + case 'SELECT_ENTITY': + const { entityId, multiSelect } = action.payload; + if (multiSelect) { + // Ctrl+click: toggle the entity in selection + const currentSelection = [...state.selectedEntities]; + const index = currentSelection.indexOf(entityId); + if (index >= 0) { + // Remove from selection (ctrl+click on selected entity) + currentSelection.splice(index, 1); + } else { + // Add to selection (ctrl+click on unselected entity) + currentSelection.push(entityId); + } + return { ...state, selectedEntities: currentSelection }; + } else { + // Regular click: replace selection with single entity + return { ...state, selectedEntities: [entityId] }; + } + case 'CLEAR_SELECTION': + return { ...state, selectedEntities: [] } + case 'SET_SELECTION': + return { ...state, selectedEntities: action.payload } + case 'ADD_ENTITY_TO_DIAGRAM': + const newEntitiesMap = new Map(state.entitiesInDiagram); + newEntitiesMap.set(action.payload.SchemaName, action.payload); + return { ...state, entitiesInDiagram: newEntitiesMap } + case 'REMOVE_ENTITY_FROM_DIAGRAM': + const updatedEntitiesMap = new Map(state.entitiesInDiagram); + updatedEntitiesMap.delete(action.payload); + return { ...state, entitiesInDiagram: updatedEntitiesMap } + case 'ADD_EXCLUDED_LINK': + const newExcludedLinks = new Map(state.excludedLinks); + // Use a key that identifies the link by source and target + const linkKey = `${action.payload.sourceSchemaName}-${action.payload.targetSchemaName}`; + newExcludedLinks.set(linkKey, action.payload); + return { ...state, excludedLinks: newExcludedLinks } + case 'REMOVE_EXCLUDED_LINK': + const updatedExcludedLinks = new Map(state.excludedLinks); + updatedExcludedLinks.delete(action.payload); + return { ...state, excludedLinks: updatedExcludedLinks } + default: + return state; + } +} + +const DiagramViewContext = createContext(initialState); +const DiagramViewDispatcher = createContext>(() => { }); + +export const DiagramViewProvider = ({ children }: { children: ReactNode }) => { + const [diagramViewState, dispatch] = useReducer(diagramViewReducer, initialState); + const selectionRef = useRef(null); + + const setZoom = (zoom: number) => { + dispatch({ type: 'SET_ZOOM', payload: zoom }); + } + + const setIsPanning = (isPanning: boolean) => { + dispatch({ type: 'SET_IS_PANNING', payload: isPanning }); + } + + const setTranslate = (translate: { x: number; y: number }) => { + dispatch({ type: 'SET_TRANSLATE', payload: translate }); + } + + const setLoadedDiagram = (filename: string | null, source: 'cloud' | 'file' | null, filePath?: string | null) => { + dispatch({ type: 'SET_LOADED_DIAGRAM', payload: { filename, source, filePath } }); + } + + const clearDiagram = () => { + // Clear the graph if it exists + if (graphRef.current) { + graphRef.current.clear(); + } + dispatch({ type: 'CLEAR_DIAGRAM' }); + } + + const setDiagramName = (name: string) => { + dispatch({ type: 'SET_DIAGRAM_NAME', payload: name }); + } + + // Refs to store graph and paper instances + const graphRef = useRef(null); + const paperRef = useRef(null); + + + useEffect(() => { + if (!diagramViewState.canvas.current) return; + + const graph = new dia.Graph({}, { + cellNamespace: { + ...shapes, + diagram: { EntityElement, RelationshipLink }, + selection: { SelectionElement } + } + }); + graphRef.current = graph; + + // Theme-aware colors using MUI CSS variables + const gridMinorColor = "var(--mui-palette-border-main)"; + const gridMajorColor = "var(--mui-palette-border-main)"; + const backgroundColor = 'var(--mui-palette-background-default)'; + + const paper = new dia.Paper({ + model: graph, + width: '100%', + height: '100%', + gridSize: 20, + drawGrid: { + name: 'doubleMesh', + args: [ + { color: gridMinorColor, thickness: 1 }, // Minor grid lines + { color: gridMajorColor, thickness: 2, scaleFactor: 5 } // Major grid lines + ] + }, + background: { + color: backgroundColor + }, + interactive: { + elementMove: true, + linkMove: false, + labelMove: true + }, + snapToGrid: true, + snapLabels: true, + frozen: true, + async: true, + cellViewNamespace: { ...shapes, diagram: { EntityElement, EntityElementView, RelationshipLink, RelationshipLinkView }, selection: { SelectionElement } } + }); + + paperRef.current = paper; + diagramViewState.canvas.current.appendChild(paper.el); + + selectionRef.current = new EntitySelection(paper); + + // Update all entity views with selection callbacks when entities are added + // Variables for panning, zooming and selection + let isPanning = false; + let panStartX = 0; + let panStartY = 0; + let currentZoom = diagramViewState.zoom; + let currentTranslate = { ...diagramViewState.translate }; + + // Mouse down handler for panning and selection + const handleMouseDown = (evt: MouseEvent) => { + if (evt.ctrlKey) { + evt.preventDefault(); + isPanning = true; + panStartX = evt.clientX; + panStartY = evt.clientY; + setIsPanning(true); + diagramViewState.canvas.current!.style.cursor = 'grabbing'; + } + }; + + // Mouse move handler for panning, selection and dragging + const handleMouseMove = (evt: MouseEvent) => { + if (isPanning && evt.ctrlKey) { + evt.preventDefault(); + const deltaX = evt.clientX - panStartX; + const deltaY = evt.clientY - panStartY; + + // Update current translate position + currentTranslate.x += deltaX; + currentTranslate.y += deltaY; + + // Apply the full transform (scale + translate) + paper.matrix({ + a: currentZoom, + b: 0, + c: 0, + d: currentZoom, + e: currentTranslate.x, + f: currentTranslate.y + }); + + // Update context state + setTranslate({ ...currentTranslate }); + + panStartX = evt.clientX; + panStartY = evt.clientY; + } + }; + + // Mouse up handler for panning and selection + const handleMouseUp = (evt: MouseEvent) => { + if (isPanning) { + evt.preventDefault(); + isPanning = false; + setIsPanning(false); + diagramViewState.canvas.current!.style.cursor = 'default'; + } + }; + + // Wheel handler for zooming and scrolling + const handleWheel = (evt: WheelEvent) => { + if (evt.ctrlKey) { + // Zoom functionality + evt.preventDefault(); + + const zoomFactor = evt.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.max(0.1, Math.min(3, currentZoom * zoomFactor)); + + if (newZoom !== currentZoom) { + // Get mouse position relative to canvas + const rect = diagramViewState.canvas.current!.getBoundingClientRect(); + const mouseX = evt.clientX - rect.left; + const mouseY = evt.clientY - rect.top; + + // Calculate zoom center offset + const zoomRatio = newZoom / currentZoom; + + // Adjust translation to zoom around mouse position + currentTranslate.x = mouseX - (mouseX - currentTranslate.x) * zoomRatio; + currentTranslate.y = mouseY - (mouseY - currentTranslate.y) * zoomRatio; + + currentZoom = newZoom; + + // Apply the full transform (scale + translate) + paper.matrix({ + a: currentZoom, + b: 0, + c: 0, + d: currentZoom, + e: currentTranslate.x, + f: currentTranslate.y + }); + + // Update context state + setZoom(newZoom); + setTranslate({ ...currentTranslate }); + } + } else { + // Scroll functionality + evt.preventDefault(); + + const scrollSpeed = 50; + + // Handle scrolling with priority for horizontal scroll + if (evt.deltaX !== 0) { + // Horizontal scroll wheel (if available) - only move horizontally + currentTranslate.x -= evt.deltaX > 0 ? scrollSpeed : -scrollSpeed; + } else if (evt.shiftKey) { + // Shift + scroll = horizontal scrolling + currentTranslate.x -= evt.deltaY > 0 ? scrollSpeed : -scrollSpeed; + } else { + // Regular scroll = vertical scrolling only + currentTranslate.y -= evt.deltaY > 0 ? scrollSpeed : -scrollSpeed; + } + + // Apply the full transform (scale + translate) + paper.matrix({ + a: currentZoom, + b: 0, + c: 0, + d: currentZoom, + e: currentTranslate.x, + f: currentTranslate.y + }); + + // Update context state + setTranslate({ ...currentTranslate }); + } + }; + + initializeRouter(graph, paper).then(() => { + const router = new AvoidRouter(graph, { + shapeBufferDistance: 20, + idealNudgingDistance: 10, + }); + + router.addGraphListeners(); + router.routeAll(); + }); + + // Add event listeners + const canvas = diagramViewState.canvas.current; + canvas.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + canvas.addEventListener('wheel', handleWheel, { passive: false }); + + // Unfreeze and render the paper to make it interactive + paper.render(); + paper.unfreeze(); + + return () => { + // Remove event listeners + canvas.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + canvas.removeEventListener('wheel', handleWheel); + + paper.remove(); + }; + }, []); + + // Create a directed link between two entity elements based on their relationship + const createDirectedLink = (graph: dia.Graph, sourceEl: dia.Element, targetEl: dia.Element, allRelationships: RelationshipInformation[]) => { + const sourceData = sourceEl.get('entityData') as EntityType; + const targetData = targetEl.get('entityData') as EntityType; + + if (!sourceData || !targetData) return; + + const link = createRelationshipLink(sourceEl.id, sourceData.SchemaName, targetEl.id, targetData.SchemaName, allRelationships); + graph.addCell(link); + }; + + // Find + add links between a *new* entity element and all existing ones (including self-referencing) + const linkNewEntityToExisting = (graph: dia.Graph, newEl: dia.Element) => { + const newData = newEl.get('entityData') as EntityType; + if (!newData) return; + + const selfReferencingRelationships = getAllRelationshipsBetween(newData, newData); + + // Check for self-referencing relationship first + if (selfReferencingRelationships.length > 0) { + // Entity has self-referencing relationship + if (!linkExistsBetween(graph, newEl.id.toString(), newEl.id.toString())) { + createDirectedLink(graph, newEl, newEl, selfReferencingRelationships); + } + } + + // Then check relationships with other entities + const existing = graph.getElements().filter(el => + el.get('type') === 'diagram.EntityElement' && el.id !== newEl.id + ); + + for (const el of existing) { + const otherData = el.get('entityData') as EntityType; + if (!otherData) continue; + + const relationships = getAllRelationshipsBetween(newData, otherData); + if (relationships.length > 0) { + if (!linkExistsBetween(graph, newEl.id.toString(), el.id.toString())) { + createDirectedLink(graph, newEl, el, relationships); + } + } + } + }; + + // Context functions + const addEntity = (entityData: EntityType, position?: { x: number; y: number }, label?: string) => { + if (graphRef.current && paperRef.current) { + let entityX: number; + let entityY: number; + + if (position) { + // If position is provided, use it as-is (already in paper coordinates) + entityX = position.x; + entityY = position.y; + } else { + // Calculate the center of the current viewport + const canvasElement = diagramViewState.canvas.current!; + const canvasRect = canvasElement.getBoundingClientRect(); + + // Get the center point of the visible canvas in screen coordinates + const centerScreenX = canvasRect.left + (canvasRect.width / 2); + const centerScreenY = canvasRect.top + (canvasRect.height / 2); + + // Convert screen coordinates to paper coordinates + const centerPaperPoint = paperRef.current.clientToLocalPoint({ + x: centerScreenX, + y: centerScreenY + }); + + entityX = centerPaperPoint.x; + entityY = centerPaperPoint.y; + } + + // Snap entity position to grid (grid size is 20px) + const gridSize = 20; + const snappedX = Math.round((entityX - 60) / gridSize) * gridSize; // Center the entity (120px width) + const snappedY = Math.round((entityY - 40) / gridSize) * gridSize; // Center the entity (80px height) + + const entityLabel = label || `Entity ${graphRef.current.getCells().length + 1}`; + + // Create the new entity using our custom EntityElement + const entity = createEntity({ + position: { x: snappedX, y: snappedY }, + title: entityLabel, + size: { width: 120, height: 80 }, + entityData + }); + + graphRef.current.addCell(entity); + + linkNewEntityToExisting(graphRef.current, entity); + + // Dispatch action to update the entities map in state + dispatch({ type: 'ADD_ENTITY_TO_DIAGRAM', payload: entityData }); + + return entity; + } + return null; + }; + + const removeEntity = (entitySchemaName: string) => { + if (graphRef.current) { + try { + const entityElement = graphRef.current.getElements().find(el => { + const elementType = el.get('type'); + const entityData = el.get('entityData'); + + const isEntityElement = elementType === 'diagram.EntityElement'; + const hasMatchingSchema = entityData?.SchemaName === entitySchemaName; + + return isEntityElement && hasMatchingSchema; + }); + + if (entityElement) { + const connectedLinks = graphRef.current.getConnectedLinks(entityElement); + + connectedLinks.forEach((link) => { + try { + link.remove(); + } catch (linkError) { + console.error('Error removing link:', link.id, linkError); + } + }); + + entityElement.remove(); + dispatch({ type: 'REMOVE_ENTITY_FROM_DIAGRAM', payload: entitySchemaName }); + } else { + console.warn('Entity not found in diagram:', entitySchemaName); + } + } catch (error) { + console.error('Error in removeEntity:', error); + } + } + }; + + const toggleRelationshipLink = (id: string, relationshipSchemaName: string, include: boolean) => { + if (graphRef.current) { + const linkElement = graphRef.current.getLinks().find(link => + link.get('type') === 'diagram.RelationshipLink' && link.id === id + ) as RelationshipLink | undefined; + + if (!linkElement) { + console.warn('Relationship link not found in diagram:', id); + return; + } + + const relations = linkElement.get('relationshipInformationList') as RelationshipInformation[]; + const updatedRelations = relations.map(rel => + rel.RelationshipSchemaName === relationshipSchemaName ? { ...rel, isIncluded: include } : rel + ); + linkElement.set('relationshipInformationList', updatedRelations); + + const allExcluded = updatedRelations.every(r => r.isIncluded === false); + if (allExcluded) { + // Store link metadata before removing + const source = linkElement.get('source') as { id: string }; + const target = linkElement.get('target') as { id: string }; + const sourceSchemaName = linkElement.get('sourceSchemaName'); + const targetSchemaName = linkElement.get('targetSchemaName'); + const labels = linkElement.labels(); + const currentLabel = labels.length > 0 ? labels[0] : undefined; + + const excludedLinkMetadata: ExcludedLinkMetadata = { + linkId: id, + sourceId: source.id, + targetId: target.id, + sourceSchemaName, + targetSchemaName, + relationshipInformationList: updatedRelations, + label: currentLabel + }; + + // Add to excluded links + dispatch({ type: 'ADD_EXCLUDED_LINK', payload: excludedLinkMetadata }); + + // Remove the link from the paper + linkElement.remove(); + } else { + linkElement.attr('line/style/strokeDasharray', ''); + linkElement.attr('line/style/stroke', 'var(--mui-palette-primary-main)'); + updateLinkMarkers(linkElement); + } + } + } + + const getGraph = () => { + return graphRef.current; + }; + + const getPaper = () => { + return paperRef.current; + }; + + const applyZoomAndPan = (zoom: number, translate: { x: number; y: number }) => { + if (paperRef.current) { + // Apply the transform matrix to the paper + paperRef.current.matrix({ + a: zoom, + b: 0, + c: 0, + d: zoom, + e: translate.x, + f: translate.y + }); + + // Update the context state + setZoom(zoom); + setTranslate(translate); + } + }; + + const selectEntity = (entityId: string, ctrlClick: boolean = false) => { + // Calculate the new selection state first + let newSelectedEntities; + if (ctrlClick) { + const currentSelection = [...diagramViewState.selectedEntities]; + const index = currentSelection.indexOf(entityId); + if (index >= 0) { + currentSelection.splice(index, 1); + } else { + currentSelection.push(entityId); + } + newSelectedEntities = currentSelection; + } else { + newSelectedEntities = [entityId]; + } + + if (graphRef.current) { + const allEntities = graphRef.current.getCells().filter(cell => cell.get('type') === 'diagram.EntityElement'); + + if (ctrlClick) { + // Ctrl+click: toggle the entity in selection - use calculated new state + const willBeSelected = newSelectedEntities.includes(entityId); + + const entity = graphRef.current.getCell(entityId); + if (entity) { + const borderColor = willBeSelected ? + '2px solid var(--mui-palette-secondary-main)' : + '2px solid var(--mui-palette-primary-main)'; + entity.attr('container/style/border', borderColor); + } + } else { + // Regular click: clear all selections visually first, then select this one + allEntities.forEach(entity => { + entity.attr('container/style/border', '2px solid var(--mui-palette-primary-main)'); + }); + + const entity = graphRef.current.getCell(entityId); + if (entity) { + entity.attr('container/style/border', '2px solid var(--mui-palette-secondary-main)'); + } + } + } + + // Update state + dispatch({ type: 'SELECT_ENTITY', payload: { entityId, multiSelect: ctrlClick } }); + }; + + const clearSelection = () => { + dispatch({ type: 'CLEAR_SELECTION' }); + + // Clear visual selection state on all entities + if (graphRef.current) { + const allEntities = graphRef.current.getCells().filter(cell => cell.get('type') === 'diagram.EntityElement'); + allEntities.forEach(entity => { + entity.attr('container/style/border', '2px solid var(--mui-palette-primary-main)'); + }); + } + }; + + const isEntityInDiagram = (entity: EntityType) => { + return diagramViewState.entitiesInDiagram.has(entity.SchemaName); + }; + + const applySmartLayout = (entities: EntityType[]) => { + if (graphRef.current && paperRef.current) { + // Get all entity elements from the graph + const entityElements = graphRef.current.getCells().filter( + cell => cell.get('type') === 'diagram.EntityElement' + ) as InstanceType[]; + + if (entityElements.length === 0) { + console.warn('No entities found to layout'); + return; + } + + const layoutEntities = entityElements.filter(el => { + const entityData = el.get('entityData') as EntityType; + return entities.some(e => e.SchemaName === entityData.SchemaName); + }); + + if (layoutEntities.length === 0) { + console.warn('No matching entities found in diagram for layout'); + return; + } + + // Create and apply the smart layout + const smartLayout = new SmartLayout(paperRef.current, layoutEntities); + smartLayout.applyLayout(); + + // Recalculate selection bounding box after layout change + if (selectionRef.current) { + selectionRef.current.recalculateBoundingBox(); + } + } else { + console.error('Graph or Paper not initialized'); + } + }; + + const getSelectedEntities = (): EntityType[] => { + if (!graphRef.current) { + return []; + } + + // Get currently selected entity IDs from state + const selectedEntityIds = diagramViewState.selectedEntities; + + if (selectedEntityIds.length === 0) { + // If no individual entity selection, check for area selection + if (selectionRef.current) { + const selectedElements = selectionRef.current.getSelected(); + return selectedElements + .filter(el => el.get('type') === 'diagram.EntityElement') + .map(el => el.get('entityData') as EntityType) + .filter(data => data != null); + } + return []; + } + + // Get entities by their IDs + const entities: EntityType[] = []; + for (const entityId of selectedEntityIds) { + const element = graphRef.current.getCell(entityId); + if (element && element.get('type') === 'diagram.EntityElement') { + const entityData = element.get('entityData') as EntityType; + if (entityData) { + entities.push(entityData); + } + } + } + + return entities; + }; + + const restoreRelationshipLink = (sourceSchemaName: string, targetSchemaName: string) => { + if (!graphRef.current) return; + + const linkKey = `${sourceSchemaName}-${targetSchemaName}`; + const excludedLink = diagramViewState.excludedLinks.get(linkKey); + + if (!excludedLink) { + console.warn('Excluded link not found:', linkKey); + return; + } + + // Recreate the link with the stored metadata + const labelText = excludedLink.label?.attrs?.label?.text; + const link = createRelationshipLink( + excludedLink.sourceId, + excludedLink.sourceSchemaName, + excludedLink.targetId, + excludedLink.targetSchemaName, + excludedLink.relationshipInformationList, + labelText + ); + link.set('id', excludedLink.linkId); + + // If we have the full label object with position data, restore it + if (excludedLink.label) { + link.labels([excludedLink.label]); + } + + graphRef.current.addCell(link); + + // Remove from excluded links + dispatch({ type: 'REMOVE_EXCLUDED_LINK', payload: linkKey }); + }; + + const getExcludedLinks = () => { + return diagramViewState.excludedLinks; + }; + + const updateRelationshipLinkLabel = (linkId: string, label: string) => { + if (!graphRef.current) return; + + const linkElement = graphRef.current.getLinks().find(link => + link.get('type') === 'diagram.RelationshipLink' && link.id === linkId + ) as RelationshipLink | undefined; + + if (!linkElement) { + console.warn('Relationship link not found in diagram:', linkId); + return; + } + + // Get existing labels + const labels = linkElement.labels(); -const DiagramViewContext = createContext(null); + if (label) { + // If label text provided + if (labels.length > 0) { + // Update existing label + const existingLabel = labels[0]; + linkElement.label(0, { + ...existingLabel, + attrs: { + ...existingLabel.attrs, + label: { + ...existingLabel.attrs?.label, + text: label + } + } + }); + } else { + // Create new label + linkElement.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 + } + } + }); + } + } else { + // If label text is empty, remove the label + if (labels.length > 0) { + linkElement.removeLabel(0); + } + } + }; -interface DiagramViewProviderProps { - children: ReactNode; + return ( + + + {children} + + + ) } -export const DiagramViewProvider: React.FC = ({ children }) => { - const diagramViewState = useDiagram(); - - return ( - - {children} - - ); -}; - -export const useDiagramViewContext = (): DiagramViewContextType => { - const context = useContext(DiagramViewContext); - if (!context) { - throw new Error('useDiagramViewContext must be used within a DiagramViewProvider'); - } - return context; -}; - -export const useDiagramViewContextSafe = (): DiagramViewContextType | null => { - const context = useContext(DiagramViewContext); - return context; -}; \ No newline at end of file +export const useDiagramView = () => useContext(DiagramViewContext); +export const useDiagramViewDispatch = () => useContext(DiagramViewDispatcher); \ No newline at end of file diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts deleted file mode 100644 index 5ec1ca5..0000000 --- a/Website/hooks/useDiagram.ts +++ /dev/null @@ -1,1169 +0,0 @@ -import { useRef, useState, useCallback, useEffect } from 'react'; -import { dia, routers, shapes } from '@joint/core'; -import { GroupType, EntityType, AttributeType } from '@/lib/Types'; -import { SquareElement } from '@/components/diagramview/elements/SquareElement'; -import { SquareElementView } from '@/components/diagramview/elements/SquareElementView'; -import { TextElement } from '@/components/diagramview/elements/TextElement'; -import { AvoidRouter } from '@/components/diagramview/avoid-router/avoidrouter'; -import { DiagramRenderer } from '@/components/diagramview/DiagramRenderer'; -import { PRESET_COLORS } from '@/components/diagramview/shared/DiagramConstants'; -import { entityStyleManager } from '@/lib/entity-styling'; - -export type DiagramType = 'simple' | 'detailed'; - -export interface DiagramState { - zoom: number; - isPanning: boolean; - selectedElements: string[]; - paper: dia.Paper | null; - graph: dia.Graph | null; - selectedGroup: GroupType | null; - currentEntities: EntityType[]; - mousePosition: { x: number; y: number } | null; - panPosition: { x: number; y: number }; - diagramType: DiagramType; -} - -export interface DiagramActions { - // Zoom - zoomIn: () => void; - zoomOut: () => void; - resetView: () => void; - fitToScreen: () => void; - setZoom: (zoom: number) => void; - - // Pan - setIsPanning: (isPanning: boolean) => void; - - // Select - selectElement: (elementId: string) => void; - selectMultipleElements: (elementIds: string[]) => void; - toggleElementSelection: (elementId: string) => void; - clearSelection: () => void; - - // Other - initializePaper: (container: HTMLElement, options?: any) => void; - destroyPaper: () => void; - selectGroup: (group: GroupType) => void; - updateMousePosition: (position: { x: number; y: number } | null) => void; - updatePanPosition: (position: { x: number; y: number }) => void; - addAttributeToEntity: (entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => void; - removeAttributeFromEntity: (entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => void; - updateDiagramType: (type: DiagramType) => void; - addEntityToDiagram: (entity: EntityType, selectedAttributes?: string[]) => void; - addGroupToDiagram: (group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => void; - removeEntityFromDiagram: (entitySchemaName: string) => void; - addSquareToDiagram: () => void; - addTextToDiagram: () => void; - saveDiagram: () => void; - loadDiagram: (file: File) => Promise; - clearDiagram: () => void; -} - -export const useDiagram = (): DiagramState & DiagramActions => { - const paperRef = useRef(null); - const graphRef = useRef(null); - const zoomRef = useRef(1); - const isPanningRef = useRef(false); - const selectedElementsRef = useRef([]); - const cleanupRef = useRef<(() => void) | null>(null); - const isAddingAttributeRef = useRef(false); - - const [zoom, setZoomState] = useState(1); - const [isPanning, setIsPanningState] = useState(false); - const [selectedElements, setSelectedElements] = useState([]); - const [selectedGroup, setSelectedGroup] = useState(null); - const [currentEntities, setCurrentEntities] = useState([]); - const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null); - const [panPosition, setPanPosition] = useState({ x: 0, y: 0 }); - const [diagramType, setDiagramType] = useState('simple'); - - // State variables to track initialization status for React dependencies - const [paperInitialized, setPaperInitialized] = useState(false); - const [graphInitialized, setGraphInitialized] = useState(false); - - // Update state when refs change (for UI updates) - const updateZoomDisplay = useCallback((newZoom: number) => { - zoomRef.current = newZoom; - setZoomState(newZoom); - }, []); - - const updatePanningDisplay = useCallback((newPanning: boolean) => { - isPanningRef.current = newPanning; - setIsPanningState(newPanning); - }, []); - - const zoomIn = useCallback(() => { - if (paperRef.current) { - const currentScale = paperRef.current.scale(); - const newScale = Math.min(currentScale.sx * 1.2, 3); - paperRef.current.scale(newScale, newScale); - updateZoomDisplay(newScale); - } - }, [updateZoomDisplay]); - - const zoomOut = useCallback(() => { - if (paperRef.current) { - const currentScale = paperRef.current.scale(); - const newScale = Math.max(currentScale.sx / 1.2, 0.1); - paperRef.current.scale(newScale, newScale); - updateZoomDisplay(newScale); - } - }, [updateZoomDisplay]); - - const resetView = useCallback(() => { - if (paperRef.current) { - paperRef.current.scale(1, 1); - paperRef.current.translate(0, 0); - updateZoomDisplay(1); - setPanPosition({ x: 0, y: 0 }); - clearSelection(); - } - }, [updateZoomDisplay]); - - const fitToScreen = useCallback(() => { - if (paperRef.current && graphRef.current) { - const elements = graphRef.current.getElements(); - if (elements.length > 0) { - const bbox = graphRef.current.getBBox(); - if (bbox) { - const paperSize = paperRef.current.getComputedSize(); - const scaleX = (paperSize.width - 100) / bbox.width; - const scaleY = (paperSize.height - 100) / bbox.height; - const scale = Math.min(scaleX, scaleY, 2); - paperRef.current.scale(scale, scale); - - // Center the content manually - const centerX = (paperSize.width - bbox.width * scale) / 2 - bbox.x * scale; - const centerY = (paperSize.height - bbox.height * scale) / 2 - bbox.y * scale; - paperRef.current.translate(centerX, centerY); - - updateZoomDisplay(scale); - setPanPosition({ x: centerX, y: centerY }); - } - } - } - }, [updateZoomDisplay]); - - const setZoom = useCallback((newZoom: number) => { - if (paperRef.current) { - paperRef.current.scale(newZoom, newZoom); - updateZoomDisplay(newZoom); - } - }, [updateZoomDisplay]); - - const setIsPanning = useCallback((newPanning: boolean) => { - updatePanningDisplay(newPanning); - }, [updatePanningDisplay]); - - const selectElement = useCallback((elementId: string) => { - const newSelection = [elementId]; - selectedElementsRef.current = newSelection; - setSelectedElements(newSelection); - }, []); - - const selectMultipleElements = useCallback((elementIds: string[]) => { - selectedElementsRef.current = elementIds; - setSelectedElements(elementIds); - }, []); - - const toggleElementSelection = useCallback((elementId: string) => { - setSelectedElements(prev => { - const newSelection = prev.includes(elementId) - ? prev.filter(id => id !== elementId) - : [...prev, elementId]; - selectedElementsRef.current = newSelection; - return newSelection; - }); - }, []); - - const clearSelection = useCallback(() => { - selectedElementsRef.current = []; - setSelectedElements([]); - }, []); - - const selectGroup = useCallback((group: GroupType) => { - setSelectedGroup(group); - - // Initialize entities with default visible attributes - const entitiesWithVisibleAttributes = group.Entities.map(entity => { - // Get primary key - const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); - - // Get custom lookup attributes (initially visible) - const customLookupAttributes = entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute - ); - - // Create initial visible attributes list - const initialVisibleAttributes = [ - ...(primaryKey ? [primaryKey.SchemaName] : []), - ...customLookupAttributes.map(attr => attr.SchemaName) - ]; - - return { - ...entity, - visibleAttributeSchemaNames: initialVisibleAttributes - }; - }); - - setCurrentEntities(entitiesWithVisibleAttributes); - clearSelection(); - }, [clearSelection]); - - const updateMousePosition = useCallback((position: { x: number; y: number } | null) => { - setMousePosition(position); - }, []); - - const updatePanPosition = useCallback((position: { x: number; y: number }) => { - setPanPosition(position); - }, []); - - const addAttributeToEntity = useCallback((entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => { - // Prevent double additions - if (isAddingAttributeRef.current) { - return; - } - - isAddingAttributeRef.current = true; - - if (!graphRef.current) { - isAddingAttributeRef.current = false; - return; - } - - // Update the currentEntities state first - setCurrentEntities(prev => { - const updated = prev.map(entity => { - if (entity.SchemaName === entitySchemaName) { - // Check if attribute already exists in the entity - const attributeExists = entity.Attributes.some((attr: AttributeType) => - attr.SchemaName === attribute.SchemaName - ); - - // Get current visible attributes list - const currentVisibleAttributes = (entity.visibleAttributeSchemaNames || []); - - if (attributeExists) { - // Attribute already exists, just add it to visible list if not already there - return { - ...entity, - visibleAttributeSchemaNames: currentVisibleAttributes.includes(attribute.SchemaName) - ? currentVisibleAttributes - : [...currentVisibleAttributes, attribute.SchemaName] - }; - } else { - // Attribute doesn't exist, add it to entity and make it visible - return { - ...entity, - Attributes: [...entity.Attributes, attribute], - visibleAttributeSchemaNames: [...currentVisibleAttributes, attribute.SchemaName] - }; - } - } - return entity; - }); - - // Update the diagram using the renderer's unified method - if (renderer) { - const updatedEntity = updated.find(e => e.SchemaName === entitySchemaName); - if (updatedEntity) { - renderer.updateEntity(entitySchemaName, updatedEntity); - } - } - - return updated; - }); - - // Reset the flag - isAddingAttributeRef.current = false; - }, []); - - const removeAttributeFromEntity = useCallback((entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => { - if (!graphRef.current) { - return; - } - - // Update the currentEntities state first - setCurrentEntities(prev => { - const updated = prev.map(entity => { - if (entity.SchemaName === entitySchemaName) { - // Remove from visible attributes list - const updatedVisibleAttributes = (entity.visibleAttributeSchemaNames || []) - .filter((attrName: string) => attrName !== attribute.SchemaName); - - return { - ...entity, - visibleAttributeSchemaNames: updatedVisibleAttributes - }; - } - return entity; - }); - - // Update the diagram using the renderer's unified method - if (renderer) { - const updatedEntity = updated.find(e => e.SchemaName === entitySchemaName); - if (updatedEntity) { - renderer.updateEntity(entitySchemaName, updatedEntity); - } - } - - return updated; - }); - }, []); - - const updateDiagramType = useCallback((type: DiagramType) => { - setDiagramType(type); - }, []); - - const addEntityToDiagram = useCallback((entity: EntityType, selectedAttributes?: string[]) => { - if (!graphRef.current || !paperRef.current) { - return; - } - - // Check if entity already exists in the diagram - const existingEntity = currentEntities.find(e => e.SchemaName === entity.SchemaName); - if (existingEntity) { - return; // Entity already in diagram - } - - let initialVisibleAttributes: string[]; - - if (selectedAttributes) { - // Use provided selected attributes - initialVisibleAttributes = selectedAttributes; - } else { - // Initialize entity with default visible attributes - const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); - const customLookupAttributes = entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute - ); - - initialVisibleAttributes = [ - ...(primaryKey ? [primaryKey.SchemaName] : []), - ...customLookupAttributes.map(attr => attr.SchemaName) - ]; - } - - const entityWithVisibleAttributes = { - ...entity, - visibleAttributeSchemaNames: initialVisibleAttributes - }; - - // Update current entities - const updatedEntities = [...currentEntities, entityWithVisibleAttributes]; - setCurrentEntities(updatedEntities); - }, [currentEntities, diagramType, fitToScreen]); - - const addGroupToDiagram = useCallback((group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => { - if (!graphRef.current || !paperRef.current) { - return; - } - - // Filter out entities that are already in the diagram - const newEntities = group.Entities.filter(entity => - !currentEntities.some(e => e.SchemaName === entity.SchemaName) - ); - - if (newEntities.length === 0) { - return; // All entities from this group are already in diagram - } - - // Initialize new entities with provided or default visible attributes - const entitiesWithVisibleAttributes = newEntities.map(entity => { - let initialVisibleAttributes: string[]; - - if (selectedAttributes && selectedAttributes[entity.SchemaName]) { - // Use the provided selected attributes - initialVisibleAttributes = selectedAttributes[entity.SchemaName]; - } else { - // Fall back to default (primary key + custom lookup attributes) - const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); - const customLookupAttributes = entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute - ); - - initialVisibleAttributes = [ - ...(primaryKey ? [primaryKey.SchemaName] : []), - ...customLookupAttributes.map(attr => attr.SchemaName) - ]; - } - - return { - ...entity, - visibleAttributeSchemaNames: initialVisibleAttributes - }; - }); - - // Update current entities with new entities from the group - const updatedEntities = [...currentEntities, ...entitiesWithVisibleAttributes]; - setCurrentEntities(updatedEntities); - }, [currentEntities]); - - const removeEntityFromDiagram = useCallback((entitySchemaName: string) => { - if (!graphRef.current) { - return; - } - - // Remove the entity from currentEntities state - const updatedEntities = currentEntities.filter(entity => entity.SchemaName !== entitySchemaName); - setCurrentEntities(updatedEntities); - - // Find and remove the entity element from the graph - const entityElement = graphRef.current.getElements().find(el => - el.get('type') === 'delegate.entity' && - el.get('data')?.entity?.SchemaName === entitySchemaName - ); - - if (entityElement) { - // Remove all links connected to this entity - const connectedLinks = graphRef.current.getConnectedLinks(entityElement); - connectedLinks.forEach(link => link.remove()); - - // Remove the entity element - entityElement.remove(); - } - }, [currentEntities, fitToScreen]); - - const addSquareToDiagram = useCallback(() => { - if (!graphRef.current || !paperRef.current) { - return; - } - - // Get all existing elements to find the lowest Y position (bottom-most) - const allElements = graphRef.current.getElements(); - let lowestY = 50; // Default starting position - - if (allElements.length > 0) { - // Find the bottom-most element and add margin - allElements.forEach(element => { - const bbox = element.getBBox(); - const elementBottom = bbox.y + bbox.height; - if (elementBottom > lowestY) { - lowestY = elementBottom + 30; // Add 30px margin - } - }); - } - - // Create a new square element - const squareElement = new SquareElement({ - position: { - x: 100, // Fixed X position - y: lowestY - }, - data: { - id: `square-${Date.now()}`, // Unique ID - borderColor: PRESET_COLORS.borders[0].value, - fillColor: PRESET_COLORS.fills[0].value, - borderWidth: 2, - borderType: 'dashed', - opacity: 0.7 - } - }); - - // Add the square to the graph - squareElement.addTo(graphRef.current); - - // Send square to the back so it renders behind entities - squareElement.toBack(); - - return squareElement; - }, []); - - const addTextToDiagram = useCallback(() => { - if (!graphRef.current || !paperRef.current) { - return; - } - - // Get all existing elements to find the lowest Y position (bottom-most) - const allElements = graphRef.current.getElements(); - let lowestY = 50; // Default starting position - - if (allElements.length > 0) { - // Find the bottom-most element and add margin - allElements.forEach(element => { - const bbox = element.getBBox(); - const elementBottom = bbox.y + bbox.height; - if (elementBottom > lowestY) { - lowestY = elementBottom + 30; // Add 30px margin - } - }); - } - - // Create a new text element - const textElement = new TextElement({ - position: { - x: 100, // Fixed X position - y: lowestY - }, - size: { width: 120, height: 25 }, - attrs: { - body: { - fill: 'transparent', - stroke: 'none' - }, - label: { - text: 'Sample Text', - fill: 'black', - fontSize: 14, - fontFamily: 'Roboto, sans-serif', - textAnchor: 'start', - textVerticalAnchor: 'top', - x: 2, - y: 2 - } - } - }); - - // Don't call updateTextElement in constructor to avoid positioning conflicts - textElement.set('data', { - text: 'Text Element', - fontSize: 14, - fontFamily: 'Roboto, sans-serif', - color: 'black', - backgroundColor: 'transparent', - padding: 8, - borderRadius: 4, - textAlign: 'left' - }); - - // Add the text to the graph - textElement.addTo(graphRef.current); - - return textElement; - }, []); - - const saveDiagram = useCallback(() => { - if (!graphRef.current) { - console.warn('No graph available to save'); - return; - } - - // Use JointJS built-in JSON export - const graphJSON = graphRef.current.toJSON(); - - // Create diagram data structure with additional metadata - const diagramData = { - version: '1.0', - timestamp: new Date().toISOString(), - diagramType, - currentEntities, - graph: graphJSON, - viewState: { - panPosition, - zoom - } - }; - - // Create blob and download - const jsonString = JSON.stringify(diagramData, null, 2); - const blob = new Blob([jsonString], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - - // Create download link - const link = document.createElement('a'); - link.href = url; - link.download = `diagram-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }, [graphRef, diagramType, currentEntities, panPosition, zoom]); - - const loadDiagram = useCallback(async (file: File): Promise => { - try { - const text = await file.text(); - const diagramData = JSON.parse(text); - - if (!graphRef.current || !paperRef.current) { - console.warn('Graph or paper not available for loading'); - return; - } - - // Clear current diagram - graphRef.current.clear(); - - // Use JointJS built-in JSON import - if (diagramData.graph) { - // Manual recreation approach since cellNamespace isn't working - const cells = diagramData.graph.cells || []; - - cells.forEach((cellData: any) => { - let cell; - - if (cellData.type === 'delegate.square') { - cell = new SquareElement({ - id: cellData.id, - position: cellData.position, - size: cellData.size, - attrs: cellData.attrs, - data: cellData.data - }); - } else if (cellData.type === 'delegate.text') { - cell = new TextElement({ - id: cellData.id, - position: cellData.position, - size: cellData.size, - attrs: cellData.attrs, - data: cellData.data - }); - } else { - try { - // Create a temporary graph to deserialize the cell - const tempGraph = new dia.Graph(); - tempGraph.fromJSON({ cells: [cellData] }); - cell = tempGraph.getCells()[0]; - } catch (error) { - console.warn('Failed to create cell:', cellData.type, error); - return; - } - } - - if (cell) { - graphRef.current!.addCell(cell); - } - }); - - } else { - console.warn('No graph data found in diagram file'); - } - - // Restore diagram type - if (diagramData.diagramType) { - setDiagramType(diagramData.diagramType); - } - - // Restore entities - if (diagramData.currentEntities) { - setCurrentEntities(diagramData.currentEntities); - } - - // Restore view settings - if (diagramData.viewState) { - const { panPosition: savedPanPosition, zoom: savedZoom } = diagramData.viewState; - - if (savedZoom && paperRef.current) { - paperRef.current.scale(savedZoom, savedZoom); - updateZoomDisplay(savedZoom); - } - - if (savedPanPosition && paperRef.current) { - paperRef.current.translate(savedPanPosition.x, savedPanPosition.y); - setPanPosition(savedPanPosition); - } - } - } catch (error) { - console.error('Failed to load diagram:', error); - console.error('Error stack:', error instanceof Error ? error.stack : 'No stack trace'); - throw new Error('Failed to load diagram file. Please check the file format.'); - } - }, [graphRef, paperRef, updateZoomDisplay]); - - const clearDiagram = useCallback(() => { - if (!graphRef.current) { - console.warn('Graph not available for clearing'); - return; - } - - // Clear the entire diagram - graphRef.current.clear(); - - // Reset currentEntities state - setCurrentEntities([]); - - // Clear selection - clearSelection(); - - }, [graphRef, clearSelection, setCurrentEntities]); - - const initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { - // Create graph if it doesn't exist - if (!graphRef.current) { - graphRef.current = new dia.Graph(); - setGraphInitialized(true); - } - - try { - await AvoidRouter.load(); - } catch (error) { - console.error('❌ Failed to initialize AvoidRouter:', error); - // Continue without avoid router if it fails - } - - let avoidRouter; - try { - avoidRouter = new AvoidRouter(graphRef.current, { - shapeBufferDistance: 10, - idealNudgingDistance: 15, - }); - avoidRouter.routeAll(); - avoidRouter.addGraphListeners(); - (routers as any).avoid = function(vertices: any, options: any, linkView: any) { - const graph = linkView.model.graph as dia.Graph; - const avoidRouterInstance = (graph as any).__avoidRouter__ as AvoidRouter; - - if (!avoidRouterInstance) { - console.warn('AvoidRouter not initialized on graph.'); - return null; - } - - const link = linkView.model as dia.Link; - - // This will update link using libavoid if possible - avoidRouterInstance.updateConnector(link); - const connRef = avoidRouterInstance.edgeRefs[link.id]; - if (!connRef) return null; - - const route = connRef.displayRoute(); - return avoidRouterInstance.getVerticesFromAvoidRoute(route); - }; - (graphRef.current as any).__avoidRouter__ = avoidRouter; - } catch (error) { - console.error('Failed to initialize AvoidRouter instance:', error); - // Continue without avoid router functionality - } - - // Create paper with light amber background - const paper = new dia.Paper({ - el: container, - model: graphRef.current, - width: '100%', - height: '100%', - gridSize: 8, - background: { - color: '#fef3c7', // Light amber background - ...options.background - }, - // Configure custom views - cellViewNamespace: { - 'delegate': { - 'square': SquareElementView - } - }, - // Disable interactive for squares when resize handles are visible - interactive: function(cellView: any) { - const element = cellView.model; - if (element.get('type') === 'delegate.square') { - const data = element.get('data') || {}; - // Disable dragging if resize handles are visible - if (data.isSelected) { - return false; - } - } - return true; // Enable dragging for other elements or unselected squares - }, - ...options - }); - - paperRef.current = paper; - setPaperInitialized(true); - - // Update entity style manager when selected elements change - const updateEntityStyleManager = () => { - entityStyleManager.handleSelectionChange( - selectedElementsRef.current, - graphRef.current!, - paper - ); - }; - - // Area selection state tracking - let isSelecting = false; - let selectionStartX = 0; - let selectionStartY = 0; - let selectionElement: SVGRectElement | null = null; - let currentAreaSelection: string[] = []; // Track current area selection - - // Create selection overlay element - const createSelectionOverlay = (x: number, y: number, width: number, height: number) => { - const paperSvg = paper.el.querySelector('svg'); - if (!paperSvg) return null; - - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - rect.setAttribute('x', x.toString()); - rect.setAttribute('y', y.toString()); - rect.setAttribute('width', width.toString()); - rect.setAttribute('height', height.toString()); - rect.setAttribute('fill', 'rgba(59, 130, 246, 0.1)'); - rect.setAttribute('stroke', '#3b82f6'); - rect.setAttribute('stroke-width', '2'); - rect.setAttribute('stroke-dasharray', '5,5'); - rect.setAttribute('pointer-events', 'none'); - rect.style.zIndex = '1000'; - - paperSvg.appendChild(rect); - return rect; - }; - - // Setup event listeners - paper.on('blank:pointerdown', (evt: any) => { - const isCtrlPressed = evt.originalEvent?.ctrlKey || evt.originalEvent?.metaKey; - - if (isCtrlPressed) { - // Ctrl + drag = pan - updatePanningDisplay(true); - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'grabbing'; - } else { - // Regular drag = area selection - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - - // Calculate start position in diagram coordinates - selectionStartX = (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - selectionStartY = (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - isSelecting = true; - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'crosshair'; - } - }); - - paper.on('blank:pointerup', () => { - if (isPanningRef.current) { - updatePanningDisplay(false); - } - - if (isSelecting) { - // Finalize selection and apply permanent visual feedback - updateEntityStyleManager(); - - isSelecting = false; - currentAreaSelection = []; // Clear the area selection tracking - // Remove selection overlay - if (selectionElement) { - selectionElement.remove(); - selectionElement = null; - } - } - - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'default'; - }); - - paper.on('blank:pointermove', (evt: any) => { - if (isPanningRef.current) { - // Handle panning - const currentTranslate = paper.translate(); - const deltaX = evt.originalEvent.movementX || 0; - const deltaY = evt.originalEvent.movementY || 0; - const newTranslateX = currentTranslate.tx + deltaX; - const newTranslateY = currentTranslate.ty + deltaY; - paper.translate(newTranslateX, newTranslateY); - updatePanPosition({ x: newTranslateX, y: newTranslateY }); - } else if (isSelecting) { - // Handle area selection - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - - // Calculate current mouse position in diagram coordinates - const currentX = (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - const currentY = (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - // Calculate selection rectangle bounds - const x = Math.min(selectionStartX, currentX); - const y = Math.min(selectionStartY, currentY); - const width = Math.abs(currentX - selectionStartX); - const height = Math.abs(currentY - selectionStartY); - - // Convert to screen coordinates for overlay - const screenX = x * currentScale.sx + currentTranslate.tx; - const screenY = y * currentScale.sy + currentTranslate.ty; - const screenWidth = width * currentScale.sx; - const screenHeight = height * currentScale.sy; - - // Update or create selection overlay - if (selectionElement) { - selectionElement.setAttribute('x', screenX.toString()); - selectionElement.setAttribute('y', screenY.toString()); - selectionElement.setAttribute('width', screenWidth.toString()); - selectionElement.setAttribute('height', screenHeight.toString()); - } else { - selectionElement = createSelectionOverlay(screenX, screenY, screenWidth, screenHeight); - } - - // Select elements within the area and provide visual feedback - if (graphRef.current && width > 10 && height > 10) { // Minimum selection size - const elementsInArea = graphRef.current.getElements().filter(element => { - const bbox = element.getBBox(); - const elementType = element.get('type'); - - // Check if element center is within selection bounds - const elementCenterX = bbox.x + bbox.width / 2; - const elementCenterY = bbox.y + bbox.height / 2; - - return elementCenterX >= x && elementCenterX <= x + width && - elementCenterY >= y && elementCenterY <= y + height; - }); - - // Update selected elements in real-time during drag - const selectedIds = elementsInArea.map(el => el.id.toString()); - currentAreaSelection = selectedIds; // Store for use in pointerup - selectedElementsRef.current = selectedIds; - setSelectedElements(selectedIds); - - // Apply visual feedback using entity style manager - entityStyleManager.handleSelectionChange(selectedIds, graphRef.current, paper); - } - } - }); - - // Group dragging state - let isGroupDragging = false; - let groupDragStartPositions: { [id: string]: { x: number; y: number } } = {}; - let dragStartMousePos = { x: 0, y: 0 }; - - // Element interaction handlers - paper.on('element:pointerdown', (elementView: dia.ElementView, evt: any) => { - const element = elementView.model; - const elementType = element.get('type'); - - const elementId = element.id.toString(); - const isCtrlPressed = evt.originalEvent?.ctrlKey || evt.originalEvent?.metaKey; - const currentSelection = selectedElementsRef.current; - - if (isCtrlPressed) { - // Ctrl+click: toggle selection - toggleElementSelection(elementId); - evt.preventDefault(); - evt.stopPropagation(); - - // Update visual feedback after a short delay to let state update - setTimeout(() => { - updateEntityStyleManager(); - }, 0); - } else if (currentSelection.includes(elementId) && currentSelection.length > 1) { - // Start group dragging if clicking on already selected element (and there are multiple selected) - isGroupDragging = true; - groupDragStartPositions = {}; - - // Store initial positions for all selected elements - currentSelection.forEach(id => { - const elem = graphRef.current?.getCell(id); - if (elem) { - const pos = elem.position(); - groupDragStartPositions[id] = { x: pos.x, y: pos.y }; - } - }); - - // Store initial mouse position - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - dragStartMousePos = { - x: (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx, - y: (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy - }; - - evt.preventDefault(); - evt.stopPropagation(); - } else if (currentSelection.includes(elementId) && currentSelection.length === 1) { - // Single selected element - allow normal JointJS dragging behavior - // Don't prevent default, let JointJS handle the dragging - return; - } else { - // Regular click: clear selection and select only this element - clearSelection(); - selectElement(elementId); - - // Update visual feedback - updateEntityStyleManager(); - } - }); - - paper.on('element:pointermove', (elementView: dia.ElementView, evt: any) => { - if (isGroupDragging && Object.keys(groupDragStartPositions).length > 0) { - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - - const currentMouseX = (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - const currentMouseY = (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - const deltaX = currentMouseX - dragStartMousePos.x; - const deltaY = currentMouseY - dragStartMousePos.y; - - // Move all selected elements - Object.keys(groupDragStartPositions).forEach(id => { - const elem = graphRef.current?.getCell(id); - if (elem) { - const startPos = groupDragStartPositions[id]; - elem.set('position', { x: startPos.x + deltaX, y: startPos.y + deltaY }); - } - }); - - evt.preventDefault(); - evt.stopPropagation(); - } - }); - - paper.on('element:pointerup', () => { - isGroupDragging = false; - groupDragStartPositions = {}; - }); - - // Clear selection when clicking on blank area (unless Ctrl+dragging) - paper.on('blank:pointerdown', (evt: any) => { - const isCtrlPressed = evt.originalEvent?.ctrlKey || evt.originalEvent?.metaKey; - - if (isCtrlPressed) { - // Ctrl + drag = pan - updatePanningDisplay(true); - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'grabbing'; - } else { - // Clear selection before starting area selection - if (!isSelecting) { - clearSelection(); - // Clear visual feedback - updateEntityStyleManager(); - } - - // Regular drag = area selection - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - const rect = (paper.el as HTMLElement).getBoundingClientRect(); - - // Calculate start position in diagram coordinates - selectionStartX = (evt.originalEvent.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - selectionStartY = (evt.originalEvent.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - isSelecting = true; - const paperEl = paper.el as HTMLElement; - paperEl.style.cursor = 'crosshair'; - } - }); - - // Add mouse move listener for coordinate tracking - const paperEl = paper.el as HTMLElement; - const handleMouseMove = (e: MouseEvent) => { - const rect = paperEl.getBoundingClientRect(); - const currentTranslate = paper.translate(); - const currentScale = paper.scale(); - - // Calculate mouse position relative to diagram coordinates - const mouseX = (e.clientX - rect.left - currentTranslate.tx) / currentScale.sx; - const mouseY = (e.clientY - rect.top - currentTranslate.ty) / currentScale.sy; - - updateMousePosition({ x: Math.round(mouseX), y: Math.round(mouseY) }); - }; - - const handleMouseLeave = () => { - updateMousePosition(null); - }; - - // Add wheel event listener for zoom - const handleWheel = (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const currentScale = paper.scale(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - const newScale = Math.max(0.1, Math.min(3, currentScale.sx * delta)); - - // Get mouse position relative to paper - const rect = paperEl.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - // Calculate zoom center - const currentTranslate = paper.translate(); - const zoomCenterX = (mouseX - currentTranslate.tx) / currentScale.sx; - const zoomCenterY = (mouseY - currentTranslate.ty) / currentScale.sy; - - // Apply zoom - paper.scale(newScale, newScale); - - // Adjust translation to zoom towards mouse position - const newTranslateX = mouseX - zoomCenterX * newScale; - const newTranslateY = mouseY - zoomCenterY * newScale; - paper.translate(newTranslateX, newTranslateY); - - updateZoomDisplay(newScale); - updatePanPosition({ x: newTranslateX, y: newTranslateY }); - }; - - paperEl.addEventListener('wheel', handleWheel); - paperEl.addEventListener('mousemove', handleMouseMove); - paperEl.addEventListener('mouseleave', handleMouseLeave); - - // Store cleanup function - cleanupRef.current = () => { - paperEl.removeEventListener('wheel', handleWheel); - paperEl.removeEventListener('mousemove', handleMouseMove); - paperEl.removeEventListener('mouseleave', handleMouseLeave); - paper.remove(); - }; - - return paper; - }, [updateZoomDisplay, updatePanningDisplay, updateMousePosition, updatePanPosition, setGraphInitialized, setPaperInitialized]); - - const destroyPaper = useCallback(() => { - if (cleanupRef.current) { - cleanupRef.current(); - cleanupRef.current = null; - } - paperRef.current = null; - graphRef.current = null; - setPaperInitialized(false); - setGraphInitialized(false); - }, [setPaperInitialized, setGraphInitialized]); - - // Update selection styling whenever selectedElements changes - useEffect(() => { - if (paperRef.current && graphRef.current) { - entityStyleManager.handleSelectionChange(selectedElements, graphRef.current, paperRef.current); - } - }, [selectedElements]); - - // Cleanup on unmount - useEffect(() => { - return () => { - destroyPaper(); - }; - }, [destroyPaper]); - - return { - // State - zoom, - isPanning, - selectedElements, - paper: paperInitialized ? paperRef.current : null, - graph: graphInitialized ? graphRef.current : null, - selectedGroup, - currentEntities, - mousePosition, - panPosition, - diagramType, - - // Actions - zoomIn, - zoomOut, - resetView, - fitToScreen, - setZoom, - setIsPanning, - selectElement, - selectMultipleElements, - toggleElementSelection, - clearSelection, - initializePaper, - destroyPaper, - selectGroup, - updateMousePosition, - updatePanPosition, - addAttributeToEntity, - removeAttributeFromEntity, - updateDiagramType, - addEntityToDiagram, - addGroupToDiagram, - removeEntityFromDiagram, - addSquareToDiagram, - addTextToDiagram, - saveDiagram, - loadDiagram, - clearDiagram, - }; -}; \ No newline at end of file diff --git a/Website/hooks/useDiagramExport.ts b/Website/hooks/useDiagramExport.ts new file mode 100644 index 0000000..de62f5c --- /dev/null +++ b/Website/hooks/useDiagramExport.ts @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; +import { DiagramPngExportService } from '@/lib/diagram/services/diagram-png-export'; +import { useSnackbar } from '@/contexts/SnackbarContext'; +import { ExportOptions } from '@/components/diagramview/modals/ExportOptionsModal'; + +type ExportTarget = 'local' | 'cloud' | null; + +export const useDiagramExport = () => { + const { canvas, diagramName } = useDiagramView(); + const [isExporting, setIsExporting] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); + const [exportTarget, setExportTarget] = useState(null); + const { showSnackbar } = useSnackbar(); + + const openExportModal = (target: ExportTarget) => { + setExportTarget(target); + setShowExportModal(true); + }; + + const closeExportModal = () => { + setShowExportModal(false); + setExportTarget(null); + }; + + const performExport = async (options: ExportOptions) => { + if (isExporting || !canvas.current || !exportTarget) { + console.warn('Export already in progress, canvas not available, or no target set'); + return; + } + + setIsExporting(true); + + try { + // Get the canvas element + const canvasElement = canvas.current; + + // Export to PNG data URL with options + showSnackbar('Generating PNG...', 'info'); + const dataUrl = await DiagramPngExportService.exportToPng(canvasElement, { + backgroundColor: options.includeGrid ? '#ffffff' : null, + scale: 2, + includeGrid: options.includeGrid, + }); + + if (exportTarget === 'local') { + // Download the PNG + const fileName = DiagramPngExportService.downloadPng(dataUrl, diagramName || 'diagram'); + showSnackbar(`Downloaded ${fileName} successfully`, 'success'); + } else if (exportTarget === 'cloud') { + // Upload to cloud + showSnackbar('Uploading to cloud...', 'info'); + const result = await DiagramPngExportService.uploadPngToCloud(dataUrl, diagramName || 'diagram'); + + if (result.success) { + showSnackbar(`Exported ${diagramName || 'diagram'}.png to cloud successfully`, 'success'); + } else { + throw new Error(result.error || 'Upload failed'); + } + } + } catch (error) { + console.error('Error exporting diagram as PNG:', error); + showSnackbar(`Failed to export diagram ${exportTarget === 'cloud' ? 'to cloud' : 'locally'}`, 'error'); + } finally { + setIsExporting(false); + setExportTarget(null); + } + }; + + const exportDiagramLocallyAsPng = () => { + openExportModal('local'); + }; + + const exportDiagramToCloudAsPng = () => { + openExportModal('cloud'); + }; + + return { + isExporting, + showExportModal, + exportTarget, + exportDiagramLocallyAsPng, + exportDiagramToCloudAsPng, + performExport, + closeExportModal, + }; +}; diff --git a/Website/hooks/useDiagramLoad.ts b/Website/hooks/useDiagramLoad.ts new file mode 100644 index 0000000..4e5fcf5 --- /dev/null +++ b/Website/hooks/useDiagramLoad.ts @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { useDiagramView, useDiagramViewDispatch } from '@/contexts/DiagramViewContext'; +import { useDatamodelData } from '@/contexts/DatamodelDataContext'; +import { DiagramDeserializationService, DiagramFile } from '@/lib/diagram/services/diagram-deserialization'; +import { EntityType } from '@/lib/Types'; +import { RelationshipInformation } from '@/lib/diagram/models/relationship-information'; + +export const useDiagramLoad = () => { + const { getGraph, applyZoomAndPan, setLoadedDiagram } = useDiagramView(); + const dispatch = useDiagramViewDispatch(); + const { getEntityDataBySchemaName } = useDatamodelData(); + const [isLoading, setIsLoading] = useState(false); + const [showLoadModal, setShowLoadModal] = useState(false); + const [availableDiagrams, setAvailableDiagrams] = useState([]); + const [isLoadingList, setIsLoadingList] = useState(false); + + const addEntityToDiagram = (entity: EntityType) => { + dispatch({ type: 'ADD_ENTITY_TO_DIAGRAM', payload: entity }); + } + + const addExcludedLink = (sourceSchemaName: string, targetSchemaName: string, linkId: string, sourceId: string, targetId: string, relationshipInformationList: RelationshipInformation[], label?: string) => { + dispatch({ + type: 'ADD_EXCLUDED_LINK', + payload: { + linkId, + sourceId, + targetId, + sourceSchemaName, + targetSchemaName, + relationshipInformationList, + label + } + }); + } + + const loadAvailableDiagrams = async () => { + setIsLoadingList(true); + try { + const diagrams = await DiagramDeserializationService.getAvailableDiagrams(); + setAvailableDiagrams(diagrams); + } catch (error) { + console.error('Error loading diagram list:', error); + } finally { + setIsLoadingList(false); + } + }; + + const loadDiagramFromCloud = async (filePath: string) => { + if (isLoading) return; + + setIsLoading(true); + + try { + const diagramData = await DiagramDeserializationService.loadDiagramFromCloud(filePath); + const graph = getGraph(); + + DiagramDeserializationService.deserializeDiagram( + diagramData, + graph, + getEntityDataBySchemaName, + applyZoomAndPan, + setLoadedDiagram, + addEntityToDiagram, + diagramData.name || 'Untitled', + 'cloud', + filePath, + addExcludedLink + ); + + setShowLoadModal(false); + + } catch (error) { + console.error('Error loading diagram from cloud:', error); + } finally { + setIsLoading(false); + } + }; + + const loadDiagramFromFile = async (file: File) => { + if (isLoading) return; + + setIsLoading(true); + + try { + const diagramData = await DiagramDeserializationService.loadDiagramFromFile(file); + const graph = getGraph(); + + DiagramDeserializationService.deserializeDiagram( + diagramData, + graph, + getEntityDataBySchemaName, + applyZoomAndPan, + setLoadedDiagram, + addEntityToDiagram, + file.name.replace('.json', ''), + 'file', + undefined, + addExcludedLink + ); + } catch (error) { + console.error('Error loading diagram from file:', error); + } finally { + setIsLoading(false); + } + }; + + const openLoadModal = () => { + setShowLoadModal(true); + }; + + const closeLoadModal = () => { + setShowLoadModal(false); + }; + + return { + isLoading, + isLoadingList, + showLoadModal, + availableDiagrams, + loadDiagramFromCloud, + loadDiagramFromFile, + loadAvailableDiagrams, + openLoadModal, + closeLoadModal + }; +}; \ No newline at end of file diff --git a/Website/hooks/useDiagramSave.ts b/Website/hooks/useDiagramSave.ts new file mode 100644 index 0000000..8060165 --- /dev/null +++ b/Website/hooks/useDiagramSave.ts @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { useDiagramView } from '@/contexts/DiagramViewContext'; +import { DiagramSerializationService } from '@/lib/diagram/services/diagram-serialization'; + +export const useDiagramSave = () => { + const { getGraph, zoom, translate, clearDiagram, setLoadedDiagram, loadedDiagramFilename, loadedDiagramSource, loadedDiagramFilePath, diagramName, getExcludedLinks } = useDiagramView(); + const [isSaving, setIsSaving] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + + const saveDiagramToCloud = async () => { + if (isSaving) return; + + setIsSaving(true); + setShowSaveModal(true); + + try { + const graph = getGraph(); + const excludedLinks = getExcludedLinks(); + const diagramData = DiagramSerializationService.serializeDiagram(graph, zoom, translate, diagramName, excludedLinks); + + // If we have a loaded diagram from cloud, preserve its name for overwriting + if (loadedDiagramSource === 'cloud' && loadedDiagramFilename) { + diagramData.name = loadedDiagramFilename; + } + + // Use overwrite functionality if we have a cloud diagram loaded + const overwriteFilePath = loadedDiagramSource === 'cloud' && loadedDiagramFilePath ? loadedDiagramFilePath : undefined; + const result = await DiagramSerializationService.saveDiagram(diagramData, overwriteFilePath) as { filePath?: string }; + + // Track that this diagram is now loaded from cloud with the correct file path + const resultFilePath = result.filePath || overwriteFilePath; + setLoadedDiagram(diagramData.name, 'cloud', resultFilePath); + } catch (error) { + console.error('Error saving diagram to cloud:', error); + } finally { + setIsSaving(false); + setShowSaveModal(false); + } + }; + + const saveDiagramLocally = () => { + if (isSaving) return; + + setIsSaving(true); + + try { + const graph = getGraph(); + const excludedLinks = getExcludedLinks(); + const diagramData = DiagramSerializationService.serializeDiagram(graph, zoom, translate, diagramName, excludedLinks); + const downloadResult = DiagramSerializationService.downloadDiagramAsJson(diagramData); + + // Track that this diagram is now loaded as a file + setLoadedDiagram(downloadResult.fileName, 'file'); + } catch (error) { + console.error('Error saving diagram locally:', error); + } finally { + setIsSaving(false); + } + }; + + const closeSaveModal = () => { + setShowSaveModal(false); + }; + + const createNewDiagram = () => { + const graph = getGraph(); + if (graph) { + graph.clear(); + } + clearDiagram(); + }; + + return { + isSaving, + showSaveModal, + saveDiagramToCloud, + saveDiagramLocally, + closeSaveModal, + createNewDiagram + }; +}; \ No newline at end of file diff --git a/Website/hooks/useRepositoryInfo.ts b/Website/hooks/useRepositoryInfo.ts new file mode 100644 index 0000000..ade5541 --- /dev/null +++ b/Website/hooks/useRepositoryInfo.ts @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'react'; + +interface RepositoryInfo { + organization: string; + repository: string; + project: string; +} + +interface UseRepositoryInfoResult { + repositoryInfo: RepositoryInfo | null; + isCloudConfigured: boolean; + isLoading: boolean; + error: string | null; +} + +export const useRepositoryInfo = (): UseRepositoryInfoResult => { + const [repositoryInfo, setRepositoryInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRepositoryInfo = async () => { + try { + const response = await fetch('/api/diagram/repository-info'); + if (!response.ok) { + throw new Error('Failed to fetch repository info'); + } + + const data = await response.json(); + setRepositoryInfo(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + setRepositoryInfo(null); + } finally { + setIsLoading(false); + } + }; + + fetchRepositoryInfo(); + }, []); + + const isCloudConfigured = Boolean( + repositoryInfo?.organization && + repositoryInfo?.repository && + repositoryInfo?.project + ); + + return { + repositoryInfo, + isCloudConfigured, + isLoading, + error + }; +}; \ No newline at end of file diff --git a/Website/lib/diagram/DiagramEventBridge.ts b/Website/lib/diagram/DiagramEventBridge.ts new file mode 100644 index 0000000..15ff632 --- /dev/null +++ b/Website/lib/diagram/DiagramEventBridge.ts @@ -0,0 +1,115 @@ +import { SelectObjectEvent } from '@/components/diagramview/events/SelectObjectEvent'; +import { EntityType } from '../Types'; +import { RelationshipInformation } from './models/relationship-information'; + +/** + * Event bridge class that provides a simple interface for both React and Joint.js components + * to dispatch and listen to diagram events without complex hook or context overhead. + */ +export class DiagramEventBridge { + private static instance: DiagramEventBridge; + + private constructor() { } + + static getInstance(): DiagramEventBridge { + if (!DiagramEventBridge.instance) { + DiagramEventBridge.instance = new DiagramEventBridge(); + } + return DiagramEventBridge.instance; + } + + dispatchEntitySelect(entityId: string, entityData: EntityType) { + const event = new CustomEvent('selectObject', { + detail: { + type: 'entity', + objectId: entityId, + data: [entityData] + } + }); + window.dispatchEvent(event); + } + + dispatchRelationshipSelect(relationshipId: string, relationshipData: RelationshipInformation | RelationshipInformation[]) { + // Ensure data is always an array + const dataArray = Array.isArray(relationshipData) ? relationshipData : [relationshipData]; + + const event = new CustomEvent('selectObject', { + detail: { + type: 'relationship', + objectId: relationshipId, + data: dataArray + } + }); + window.dispatchEvent(event); + } + + dispatchSelectionChange(entities: EntityType[]) { + const event = new CustomEvent('selectObject', { + detail: { + type: 'selection', + objectId: undefined, + data: entities + } + }); + window.dispatchEvent(event); + } + + dispatchClear() { + const event = new CustomEvent('selectObject', { + detail: { + type: 'none', + objectId: undefined, + data: [] + } + }); + window.dispatchEvent(event); + } + + dispatchEntityContextMenu(entityId: string, x: number, y: number) { + const event = new CustomEvent('entityContextMenu', { + detail: { entityId, x, y } + }); + window.dispatchEvent(event); + } + + // Event listening methods - can be used by React components + addEventListener(eventType: 'selectObject', handler: (event: CustomEvent) => void): void; + addEventListener(eventType: 'entityContextMenu', handler: (event: CustomEvent) => void): void; + addEventListener(eventType: string, handler: (event: CustomEvent) => void): void { + window.addEventListener(eventType, handler as EventListener); + } + + removeEventListener(eventType: 'selectObject', handler: (event: CustomEvent) => void): void; + removeEventListener(eventType: 'entityContextMenu', handler: (event: CustomEvent) => void): void; + removeEventListener(eventType: string, handler: (event: CustomEvent) => void): void { + window.removeEventListener(eventType, handler as EventListener); + } + + // Convenience method for React components that need to handle selection events + onSelectionEvent(callback: (event: SelectObjectEvent) => void): () => void { + const handler = (evt: CustomEvent) => { + callback(evt.detail); + }; + + this.addEventListener('selectObject', handler); + + // Return cleanup function + return () => this.removeEventListener('selectObject', handler); + } + + // Convenience method for React components that need to handle context menu events + onContextMenuEvent(callback: (entityId: string, x: number, y: number) => void): () => void { + const handler = (evt: CustomEvent) => { + const { entityId, x, y } = evt.detail; + callback(entityId, x, y); + }; + + this.addEventListener('entityContextMenu', handler); + + // Return cleanup function + return () => this.removeEventListener('entityContextMenu', handler); + } +} + +// Export singleton instance for easy access +export const diagramEvents = DiagramEventBridge.getInstance(); \ No newline at end of file diff --git a/Website/lib/diagram/models/relationship-information.ts b/Website/lib/diagram/models/relationship-information.ts new file mode 100644 index 0000000..36d63dd --- /dev/null +++ b/Website/lib/diagram/models/relationship-information.ts @@ -0,0 +1,10 @@ +export type RelationshipInformation = { + sourceEntitySchemaName: string, + sourceEntityDisplayName: string, + targetEntitySchemaName: string, + targetEntityDisplayName: string, + RelationshipType: '1-M' | 'M-1' | 'M-M' | 'SELF', + RelationshipSchemaName: string, + IsManyToMany: boolean, + isIncluded?: boolean, +} \ No newline at end of file diff --git a/Website/lib/diagram/models/serialized-diagram.ts b/Website/lib/diagram/models/serialized-diagram.ts new file mode 100644 index 0000000..ba827b2 --- /dev/null +++ b/Website/lib/diagram/models/serialized-diagram.ts @@ -0,0 +1,17 @@ +import { SerializedEntity } from "./serialized-entity"; +import { SerializedLink } from "./serialized-link"; + +export interface SerializedDiagram { + id: string; + name: string; + version: string; + createdAt: string; + updatedAt: string; + metadata: { + zoom: number; + translate: { x: number; y: number }; + canvasSize: { width: number; height: number }; + }; + entities: SerializedEntity[]; + links: SerializedLink[]; +} \ No newline at end of file diff --git a/Website/lib/diagram/models/serialized-entity.ts b/Website/lib/diagram/models/serialized-entity.ts new file mode 100644 index 0000000..237be5d --- /dev/null +++ b/Website/lib/diagram/models/serialized-entity.ts @@ -0,0 +1,8 @@ +export interface SerializedEntity { + id: string; + type: string; + position: { x: number; y: number }; + size: { width: number; height: number }; + label: string; + schemaName: string; +} \ No newline at end of file diff --git a/Website/lib/diagram/models/serialized-link.ts b/Website/lib/diagram/models/serialized-link.ts new file mode 100644 index 0000000..a979dba --- /dev/null +++ b/Website/lib/diagram/models/serialized-link.ts @@ -0,0 +1,15 @@ +export interface SerializedRelationship { + schemaName: string; + isIncluded?: boolean; +} + +export interface SerializedLink { + id: string; + sourceId: string; + sourceSchemaName: string; + targetId: string; + targetSchemaName: string; + relationships: SerializedRelationship[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + label?: any; // Store the full JointJS label object +} diff --git a/Website/lib/diagram/relationship-helpers.ts b/Website/lib/diagram/relationship-helpers.ts new file mode 100644 index 0000000..ea1230b --- /dev/null +++ b/Website/lib/diagram/relationship-helpers.ts @@ -0,0 +1,93 @@ +import { EntityType } from '../Types'; +import { RelationshipInformation } from './models/relationship-information'; +import { dia } from '@joint/core'; + +/** + * Collect ALL relationships between two entities + * Only uses the Relationships array - ignores lookup attributes as they're redundant + * All relationships are normalized to be relative to sourceEntity -> targetEntity direction + */ +export const getAllRelationshipsBetween = (sourceEntity: EntityType, targetEntity: EntityType): RelationshipInformation[] => { + if (!sourceEntity || !targetEntity) return []; + + const relationships: RelationshipInformation[] = []; + const isSelfReferencing = sourceEntity.SchemaName === targetEntity.SchemaName; + const seenSchemas = new Set(); // Track relationship schemas to avoid duplicates + + // Helper to add relationship if not duplicate + const addRelationship = (rel: RelationshipInformation) => { + // For M-M relationships, use schema to detect duplicates + if (rel.IsManyToMany && rel.RelationshipSchemaName) { + if (seenSchemas.has(rel.RelationshipSchemaName)) { + return; // Skip duplicate M-M relationship + } + seenSchemas.add(rel.RelationshipSchemaName); + } + rel.isIncluded = true; + relationships.push(rel); + }; + + // Collect relationships from SOURCE entity pointing to TARGET + // These are 1-M relationships where source is the "1" (parent) side + if (sourceEntity.Relationships) { + sourceEntity.Relationships.forEach(rel => { + if (rel.TableSchema?.toLowerCase() === targetEntity.SchemaName.toLowerCase()) { + const direction = rel.IsManyToMany ? 'M-M' : (isSelfReferencing ? 'SELF' : '1-M'); + addRelationship({ + sourceEntitySchemaName: sourceEntity.SchemaName, + sourceEntityDisplayName: sourceEntity.DisplayName, + targetEntitySchemaName: targetEntity.SchemaName, + targetEntityDisplayName: targetEntity.DisplayName, + RelationshipType: direction, + RelationshipSchemaName: rel.RelationshipSchema, + IsManyToMany: rel.IsManyToMany, + }); + } + }); + } + + // If not self-referencing, collect relationships from TARGET entity pointing to SOURCE + // These represent M-1 relationships from source's perspective (target is "1", source is "many") + if (!isSelfReferencing && targetEntity.Relationships) { + targetEntity.Relationships.forEach(rel => { + if (rel.TableSchema?.toLowerCase() === sourceEntity.SchemaName.toLowerCase()) { + // Normalize to source -> target perspective + // Target pointing to source means: target (many) -> source (one) + // From source perspective: source (one) <- target (many) = M-1 + const direction = rel.IsManyToMany ? 'M-M' : 'M-1'; + addRelationship({ + sourceEntitySchemaName: sourceEntity.SchemaName, + sourceEntityDisplayName: sourceEntity.DisplayName, + targetEntitySchemaName: targetEntity.SchemaName, + targetEntityDisplayName: targetEntity.DisplayName, + RelationshipType: direction, + RelationshipSchemaName: rel.RelationshipSchema, + IsManyToMany: rel.IsManyToMany, + }); + } + }); + } + + return relationships; +}; + +/** + * Check if a link already exists between two element ids (including self-referencing) + */ +export const linkExistsBetween = (graph: dia.Graph, aId: string, bId: string): boolean => { + const links = graph.getLinks(); + return links.some(l => { + const s = l.get('source'); + const t = l.get('target'); + const sId = typeof s?.id === 'string' ? s.id : s?.id?.toString?.(); + const tId = typeof t?.id === 'string' ? t.id : t?.id?.toString?.(); + + // Handle self-referencing links (same source and target) + if (aId === bId) { + return sId === aId && tId === bId; + } + + // Handle regular links (bidirectional check) + return (sId === aId && tId === bId) || (sId === bId && tId === aId); + }); +}; \ No newline at end of file diff --git a/Website/lib/diagram/services/diagram-deserialization.ts b/Website/lib/diagram/services/diagram-deserialization.ts new file mode 100644 index 0000000..3d14701 --- /dev/null +++ b/Website/lib/diagram/services/diagram-deserialization.ts @@ -0,0 +1,176 @@ +import { dia } from '@joint/core'; +import { SerializedDiagram } from '../models/serialized-diagram'; +import { SerializedEntity } from '../models/serialized-entity'; +import { createEntity } from '@/components/diagramview/diagram-elements/EntityElement'; +import { createRelationshipLink } from '@/components/diagramview/diagram-elements/RelationshipLink'; +import { getAllRelationshipsBetween } from '../relationship-helpers'; +import { EntityType } from '@/lib/Types'; +import { RelationshipInformation } from '../models/relationship-information'; + +export interface DiagramFile { + path: string; + name: string; + createdAt: string; + updatedAt: string; + size: number; +} + +export class DiagramDeserializationService { + static async loadDiagramFromCloud(filePath: string): Promise { + const response = await fetch('/api/diagram/load', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ filePath }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to load diagram'); + } + + return response.json(); + } + + static async getAvailableDiagrams(): Promise { + const response = await fetch('/api/diagram/list'); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to get diagram list'); + } + + return response.json(); + } + + static deserializeDiagram( + diagramData: SerializedDiagram, + graph: dia.Graph | null, + getEntityDataBySchemaName: (schemaName: string) => EntityType | undefined, + applyZoomAndPan: (zoom: number, translate: { x: number; y: number }) => void, + setLoadedDiagram: (filename: string | null, source: 'cloud' | 'file' | null, filePath?: string | null) => void, + addEntityToDiagram: (entity: EntityType) => void, + filename: string, + source: 'cloud' | 'file', + filePath?: string, + addExcludedLink?: (sourceSchemaName: string, targetSchemaName: string, linkId: string, sourceId: string, targetId: string, relationshipInformationList: RelationshipInformation[], label?: string) => void + ): void { + if (!graph) { + throw new Error('No diagram graph available for deserialization'); + } + + // Clear existing diagram + graph.clear(); + + // Restore zoom and pan - apply directly to the paper + applyZoomAndPan(diagramData.metadata.zoom, diagramData.metadata.translate); + + // Set loaded diagram info + setLoadedDiagram(filename, source, filePath); + + // Recreate entities + diagramData.entities.forEach((entityData: SerializedEntity) => { + const data = getEntityDataBySchemaName(entityData.schemaName); + if (!data) { + console.warn(`Entity data not found for schema: ${entityData.schemaName}`); + return; + } + + const entity = createEntity({ + position: entityData.position, + size: entityData.size, + title: entityData.label, + entityData: data + }); + entity.set('id', entityData.id); + addEntityToDiagram(data); + + graph.addCell(entity); + }); + + // Recreate links with relationship information (if available) + if (diagramData.links && diagramData.links.length > 0) { + diagramData.links.forEach((linkData) => { + const source = getEntityDataBySchemaName(linkData.sourceSchemaName); + const target = getEntityDataBySchemaName(linkData.targetSchemaName); + + if (!source || !target) { + console.warn(`Source or target entity not found for link: ${linkData.sourceSchemaName} -> ${linkData.targetSchemaName}`); + return; + } + + const relations = getAllRelationshipsBetween(source, target).map((rel) => { + const relInfo = linkData.relationships.find(r => r.schemaName === rel.RelationshipSchemaName); + return { + ...rel, + isIncluded: relInfo ? relInfo.isIncluded : undefined + }; + }); + + // Check if all relationships are excluded + const allExcluded = relations.every(rel => rel.isIncluded === false); + + if (allExcluded && addExcludedLink) { + // Don't add to graph, add to excluded links instead + addExcludedLink( + linkData.sourceSchemaName, + linkData.targetSchemaName, + linkData.id, + linkData.sourceId, + linkData.targetId, + relations, + linkData.label + ); + } else { + // Add the link to the graph + const labelText = linkData.label?.attrs?.label?.text; + const link = createRelationshipLink( + linkData.sourceId, + linkData.sourceSchemaName, + linkData.targetId, + linkData.targetSchemaName, + relations, + labelText + ); + link.set('id', linkData.id); + + // If we have the full label object with position data, restore it + if (linkData.label) { + link.labels([linkData.label]); + } + + graph.addCell(link); + } + }); + } + } + + static loadDiagramFromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + try { + const content = event.target?.result as string; + const diagramData = JSON.parse(content) as SerializedDiagram; + + // Validate the diagram data structure + if (!diagramData.id || !diagramData.entities || !diagramData.metadata) { + throw new Error('Invalid diagram file format'); + } + + resolve(diagramData); + } catch (error) { + reject(new Error(`Failed to parse diagram file: ${error instanceof Error ? error.message : 'Unknown error'}`)); + } + }; + + reader.onerror = () => { + reject(new Error('Failed to read diagram file')); + }; + + reader.readAsText(file); + }); + } +} \ No newline at end of file diff --git a/Website/lib/diagram/services/diagram-png-export.ts b/Website/lib/diagram/services/diagram-png-export.ts new file mode 100644 index 0000000..0ec1e73 --- /dev/null +++ b/Website/lib/diagram/services/diagram-png-export.ts @@ -0,0 +1,389 @@ +import html2canvas from 'html2canvas'; + +export interface PngExportOptions { + backgroundColor?: string | null; + scale?: number; + width?: number; + height?: number; + includeGrid?: boolean; // Whether to include the grid background +} + +export class DiagramPngExportService { + + /** + * Calculates the bounding box of all diagram content (excluding grid) + * @param canvasElement - The diagram canvas DOM element + * @param padding - Padding to add around content in pixels + * @returns Bounding box { x, y, width, height } + */ + private static getContentBoundingBox( + canvasElement: HTMLElement, + padding: number = 20 + ): { x: number; y: number; width: number; height: number } | null { + const svg = canvasElement.querySelector('svg'); + if (!svg) { + console.warn('[DiagramPngExport] No SVG found in canvas'); + return null; + } + + // Get all elements except grid-related ones + const allElements = Array.from(svg.querySelectorAll('g[model-id], g.joint-element, g.joint-link')); + + if (allElements.length === 0) { + console.warn('[DiagramPngExport] No diagram elements found'); + return null; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + // Calculate bounding box from all diagram elements + allElements.forEach((element) => { + try { + const bbox = (element as SVGGraphicsElement).getBBox(); + minX = Math.min(minX, bbox.x); + minY = Math.min(minY, bbox.y); + maxX = Math.max(maxX, bbox.x + bbox.width); + maxY = Math.max(maxY, bbox.y + bbox.height); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + // Some elements might not support getBBox, skip them + } + }); + + // If we couldn't calculate bounds, return null + if (minX === Infinity || minY === Infinity) { + console.warn('[DiagramPngExport] Could not calculate content bounds'); + return null; + } + + // Add padding + const x = Math.max(0, minX - padding); + const y = Math.max(0, minY - padding); + const width = (maxX - minX) + (padding * 2); + const height = (maxY - minY) + (padding * 2); + + console.log('[DiagramPngExport] Content bounds:', { x, y, width, height }); + return { x, y, width, height }; + } + + /** + * Resolves CSS variables in an element by computing and applying actual values + * @param original - Original element to read computed styles from + * @param clone - Cloned element to apply resolved styles to + */ + private static resolveCssVariables(original: HTMLElement, clone: HTMLElement): void { + // Get computed style of original element + const computedStyle = window.getComputedStyle(original); + + // Apply computed values to clone for properties that might use CSS variables + const propertiesToResolve = [ + 'backgroundColor', + 'borderColor', + 'borderTopColor', + 'borderRightColor', + 'borderBottomColor', + 'borderLeftColor', + 'borderTopStyle', + 'borderRightStyle', + 'borderBottomStyle', + 'borderLeftStyle', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'borderRadius', + 'color', + 'fill', + 'stroke' + ]; + + propertiesToResolve.forEach(prop => { + const value = computedStyle.getPropertyValue(prop); + if (value && value !== '' && value !== 'none') { + // Force set the style with !important to override any inline styles + clone.style.setProperty(prop, value, 'important'); + } + }); + + // Recursively resolve CSS variables for all children (including SVG and foreignObject) + const originalChildren = Array.from(original.children); + const cloneChildren = Array.from(clone.children); + + for (let i = 0; i < originalChildren.length; i++) { + const originalChild = originalChildren[i]; + const cloneChild = cloneChildren[i]; + + if (originalChild instanceof HTMLElement && cloneChild instanceof HTMLElement) { + this.resolveCssVariables(originalChild, cloneChild); + } else if (originalChild instanceof SVGElement && cloneChild instanceof SVGElement) { + // Handle SVG elements specially + this.resolveSvgCssVariables(originalChild, cloneChild); + } + } + } + + /** + * Resolves CSS variables in SVG elements + * @param original - Original SVG element to read computed styles from + * @param clone - Cloned SVG element to apply resolved styles to + */ + private static resolveSvgCssVariables(original: SVGElement, clone: SVGElement): void { + // Get computed style for SVG element + const computedStyle = window.getComputedStyle(original); + + // SVG-specific properties + const svgProps = ['fill', 'stroke', 'stroke-width', 'stroke-dasharray']; + + svgProps.forEach(prop => { + const value = computedStyle.getPropertyValue(prop); + if (value && value !== '' && value !== 'none') { + clone.setAttribute(prop, value); + } + }); + + // Recursively handle SVG children + const originalChildren = Array.from(original.children); + const cloneChildren = Array.from(clone.children); + + for (let i = 0; i < originalChildren.length; i++) { + const originalChild = originalChildren[i]; + const cloneChild = cloneChildren[i]; + + if (originalChild instanceof HTMLElement && cloneChild instanceof HTMLElement) { + this.resolveCssVariables(originalChild, cloneChild); + } else if (originalChild instanceof SVGElement && cloneChild instanceof SVGElement) { + this.resolveSvgCssVariables(originalChild, cloneChild); + } + } + } + + /** + * Clones canvas and removes grid elements from the clone + * @param canvasElement - The diagram canvas DOM element + * @returns Cloned element with grid removed + */ + private static cloneAndRemoveGrid(canvasElement: HTMLElement): HTMLElement { + // Clone the canvas element (deep clone to get all children) + const clone = canvasElement.cloneNode(true) as HTMLElement; + + // Resolve CSS variables before removing grid (so we can compute styles correctly) + this.resolveCssVariables(canvasElement, clone); + + // Remove background color from the main container (makes it transparent) + clone.style.backgroundColor = 'transparent'; + + // Also check for .joint-paper element and make it transparent + const jointPaper = clone.querySelector('.joint-paper'); + if (jointPaper) { + (jointPaper as HTMLElement).style.backgroundColor = 'transparent'; + } + + // Remove JointJS grid-related elements from the clone only + clone.querySelector('.joint-paper-background')?.remove(); + clone.querySelector('.joint-grid-layer')?.remove(); + + console.log('[DiagramPngExport] Created clone without grid and background'); + return clone; + } + + /** + * Crops a canvas to the specified bounding box + * @param sourceCanvas - The canvas to crop + * @param bounds - The bounding box to crop to + * @param scale - The scale factor used in rendering + * @returns Cropped canvas + */ + private static cropCanvas( + sourceCanvas: HTMLCanvasElement, + bounds: { x: number; y: number; width: number; height: number }, + scale: number + ): HTMLCanvasElement { + const croppedCanvas = document.createElement('canvas'); + const ctx = croppedCanvas.getContext('2d'); + + if (!ctx) { + throw new Error('Failed to get canvas 2D context'); + } + + // Apply scale to bounds + const scaledX = bounds.x * scale; + const scaledY = bounds.y * scale; + const scaledWidth = bounds.width * scale; + const scaledHeight = bounds.height * scale; + + // Set cropped canvas dimensions + croppedCanvas.width = scaledWidth; + croppedCanvas.height = scaledHeight; + + // Draw the cropped portion + ctx.drawImage( + sourceCanvas, + scaledX, scaledY, scaledWidth, scaledHeight, // Source rectangle + 0, 0, scaledWidth, scaledHeight // Destination rectangle + ); + + console.log('[DiagramPngExport] Cropped canvas to content bounds'); + return croppedCanvas; + } + + /** + * Converts a canvas element to PNG data URL + * @param canvasElement - The diagram canvas DOM element + * @param options - Export options for customization + * @returns Promise resolving to base64 PNG data URL + */ + static async exportToPng( + canvasElement: HTMLElement, + options: PngExportOptions = {} + ): Promise { + const { + backgroundColor, // Don't set default - let it be undefined/null for transparency + scale = 2, // Higher scale for better quality + width, + height, + includeGrid = true + } = options; + + // Use white background by default only if grid is included and no background specified + const finalBackgroundColor = backgroundColor !== undefined + ? backgroundColor + : (includeGrid ? '#ffffff' : null); + + let clonedElement: HTMLElement | null = null; + + try { + // Calculate content bounding box before any modifications + const contentBounds = this.getContentBoundingBox(canvasElement, 20); + + // Use clone without grid if requested, otherwise use original + const elementToCapture = includeGrid + ? canvasElement + : this.cloneAndRemoveGrid(canvasElement); + + // If using clone, temporarily add it to DOM (html2canvas needs it in DOM) + if (!includeGrid && elementToCapture !== canvasElement) { + clonedElement = elementToCapture; + clonedElement.style.position = 'absolute'; + clonedElement.style.left = '-9999px'; + clonedElement.style.top = '0'; + document.body.appendChild(clonedElement); + } + + const canvas = await html2canvas(elementToCapture, { + backgroundColor: finalBackgroundColor, + scale, + width, + height, + useCORS: true, + logging: false, + allowTaint: true, + }); + + // Remove clone from document if we added it + if (clonedElement && document.body.contains(clonedElement)) { + document.body.removeChild(clonedElement); + } + + // Crop canvas to content bounds if we calculated them + const finalCanvas = contentBounds + ? this.cropCanvas(canvas, contentBounds, scale) + : canvas; + + return finalCanvas.toDataURL('image/png'); + } catch (error) { + // Clean up clone even if export fails + if (clonedElement && document.body.contains(clonedElement)) { + document.body.removeChild(clonedElement); + } + + console.error('Error exporting diagram to PNG:', error); + throw new Error('Failed to export diagram as PNG'); + } + } + + /** + * Downloads a PNG data URL as a file + * @param dataUrl - Base64 PNG data URL + * @param fileName - Name for the downloaded file (without extension) + * @returns The full filename with extension + */ + static downloadPng(dataUrl: string, fileName: string): string { + const fullFileName = `${fileName}.png`; + + // Create a temporary link element + const link = document.createElement('a'); + link.href = dataUrl; + link.download = fullFileName; + + // Trigger download + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + return fullFileName; + } + + /** + * Converts base64 data URL to blob for upload + * @param dataUrl - Base64 PNG data URL + * @returns Blob object + */ + static dataUrlToBlob(dataUrl: string): Blob { + const arr = dataUrl.split(','); + const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png'; + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); + + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + + return new Blob([u8arr], { type: mime }); + } + + /** + * Converts base64 data URL to base64 string (without data URL prefix) + * @param dataUrl - Base64 PNG data URL + * @returns Pure base64 string + */ + static dataUrlToBase64(dataUrl: string): string { + return dataUrl.split(',')[1]; + } + + /** + * Uploads PNG to cloud storage via API + * @param dataUrl - Base64 PNG data URL + * @param fileName - Name for the file (without extension) + * @returns Promise resolving to API response + */ + static async uploadPngToCloud(dataUrl: string, fileName: string): Promise<{ success: boolean; filePath?: string; error?: string }> { + try { + const base64Data = this.dataUrlToBase64(dataUrl); + + const response = await fetch('/api/diagram/export-png', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fileName: `${fileName}.png`, + imageData: base64Data, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to upload PNG to cloud'); + } + + return await response.json(); + } catch (error) { + console.error('Error uploading PNG to cloud:', error); + throw error; + } + } +} diff --git a/Website/lib/diagram/services/diagram-serialization.ts b/Website/lib/diagram/services/diagram-serialization.ts new file mode 100644 index 0000000..2eda58f --- /dev/null +++ b/Website/lib/diagram/services/diagram-serialization.ts @@ -0,0 +1,153 @@ +import { dia } from '@joint/core'; +import { SerializedEntity } from '../models/serialized-entity'; +import { SerializedDiagram } from '../models/serialized-diagram'; +import { SerializedLink } from '../models/serialized-link'; +import { RelationshipInformation } from '../models/relationship-information'; +import { EntityElement } from '@/components/diagramview/diagram-elements/EntityElement'; +import { RelationshipLink } from '@/components/diagramview/diagram-elements/RelationshipLink'; +import { ExcludedLinkMetadata } from '@/contexts/DiagramViewContext'; + +export class DiagramSerializationService { + static serializeDiagram( + graph: dia.Graph | null, + zoom: number, + translate: { x: number; y: number }, + diagramName: string, + excludedLinks?: Map + ): SerializedDiagram { + if (!graph) { + throw new Error('No diagram graph available'); + } + + const cells = graph.getCells(); + const entities: SerializedEntity[] = []; + const links: SerializedLink[] = []; + + cells.forEach((cell) => { + if (cell.isElement()) { + // This is an entity/element + const element = cell as EntityElement; + const position = element.position(); + const size = element.size(); + const attrs = element.attributes.attrs || {}; + + entities.push({ + id: element.id.toString(), + type: element.attributes.type || 'standard.Rectangle', + position: { x: position.x, y: position.y }, + size: { width: size.width, height: size.height }, + label: element.get('label') || attrs.body?.text || `Entity ${entities.length + 1}`, + schemaName: element.get('entityData').SchemaName + }); + } else if (cell.isLink()) { + // This is a link/relationship + const link = cell as RelationshipLink; + const source = link.get('source'); + const target = link.get('target'); + const relationshipInformationList = link.get('relationshipInformationList') as RelationshipInformation[] || []; + const labels = link.labels(); + const label = labels.length > 0 ? labels[0] : undefined; + + // Only save links that have relationship information + if (relationshipInformationList.length > 0 && source?.id && target?.id) { + links.push({ + id: link.id.toString(), + sourceId: source.id.toString(), + sourceSchemaName: link.get('sourceSchemaName'), + targetId: target.id.toString(), + targetSchemaName: link.get('targetSchemaName'), + relationships: relationshipInformationList.map((relInfo) => ({ + schemaName: relInfo.RelationshipSchemaName, + isIncluded: relInfo.isIncluded + })), + label: label + }); + } + } + }); + + // Add excluded links to the serialized diagram + if (excludedLinks && excludedLinks.size > 0) { + excludedLinks.forEach((excludedLink) => { + links.push({ + id: excludedLink.linkId, + sourceId: excludedLink.sourceId, + sourceSchemaName: excludedLink.sourceSchemaName, + targetId: excludedLink.targetId, + targetSchemaName: excludedLink.targetSchemaName, + relationships: excludedLink.relationshipInformationList.map((relInfo) => ({ + schemaName: relInfo.RelationshipSchemaName, + isIncluded: relInfo.isIncluded + })), + label: excludedLink.label + }); + }); + } + + return { + id: crypto.randomUUID(), + name: diagramName, + version: '1.0.0', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: { + zoom, + translate, + canvasSize: { width: 1920, height: 1080 } // Default canvas size + }, + entities, + links + }; + } + + static async saveDiagram(diagramData: SerializedDiagram, overwriteFilePath?: string): Promise { + const requestBody = overwriteFilePath + ? { ...diagramData, overwriteFilePath } + : diagramData; + + const response = await fetch('/api/diagram/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save diagram'); + } + + return response.json(); + } + + static downloadDiagramAsJson(diagramData: SerializedDiagram): { fileName: string; success: boolean } { + try { + const jsonString = JSON.stringify(diagramData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + // Create a download URL + const url = URL.createObjectURL(blob); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `${diagramData.name}_${timestamp}.json`; + + // Create a temporary anchor element and trigger download + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.style.display = 'none'; + + // Append to body, click, and remove + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + // Clean up the URL object + URL.revokeObjectURL(url); + + return { fileName, success: true }; + } catch (error) { + console.error('Error downloading diagram as JSON:', error); + throw new Error('Failed to download diagram file'); + } + } +} \ No newline at end of file diff --git a/Website/lib/icons.tsx b/Website/lib/icons.tsx index abfc02c..0fdb2b2 100644 --- a/Website/lib/icons.tsx +++ b/Website/lib/icons.tsx @@ -7,20 +7,20 @@ export const ProcessesIcon = ; export const ComplianceIcon = - - - + + + export const SolutionIcon = - - - + + + export const OverviewIcon = - - + + export const ComponentIcon = @@ -36,6 +36,81 @@ export const WarningIcon = - - + + +; + +export const AddSquareIcon = + + +; + +export const FileMenuIcon = + + +; + +export const LoadIcon = + + + + +; + +export const CloudSaveIcon = + + +; + +export const CloudNewIcon = + + +; + +export const LocalSaveIcon = + + + + +; + +export const NewIcon = + + +; + +export const AzureDevOpsIcon = + +; + +export const ArchiveIcon = + + +; + +export const CloseIcon = + + +; + +export const PathConnectionIcon = + + + + + +export const BinIcon = + + + +; + +export const CloudExportIcon = + + +; + +export const ExportIcon = + + ; \ No newline at end of file diff --git a/Website/package-lock.json b/Website/package-lock.json index cea30e3..903562f 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -8,6 +8,7 @@ "name": "website", "version": "2.1.1", "dependencies": { + "@azure/identity": "^4.13.0", "@joint/core": "^4.1.3", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", @@ -20,6 +21,7 @@ "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html2canvas": "^1.4.1", "jose": "^5.9.6", "libavoid-js": "^0.4.5", "next": "^15.5.3", @@ -56,6 +58,164 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.1.tgz", + "integrity": "sha512-UVZlVLfLyz6g3Hy7GNDpooMQonUygH7ghdiSASOOHy97fKj/mPLqgDX7aidOijn+sCMU+WU8NjlPlNTgnvbcGA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", + "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.25.0.tgz", + "integrity": "sha512-kbL+Ae7/UC62wSzxirZddYeVnHvvkvAnSZkBqL55X+jaSXTAXfngnNsDM5acEWU0Q/SAv3gEQfxO1igWOn87Pg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.13.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.0.tgz", + "integrity": "sha512-8oF6nj02qX7eE/6+wFT5NluXRHc05AgdCC3fJnkjiJooq8u7BcLmxaYYSwc2AfEkWRMRi6Eyvvbeqk4U4412Ag==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.0.tgz", + "integrity": "sha512-23BXm82Mp5XnRhrcd4mrHa0xuUNRp96ivu3nRatrfdAqjoeWAGyD0eEAafxAOHAEWWmdlyFK4ELFcdziXyw2sA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2633,6 +2793,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.1.tgz", + "integrity": "sha512-SnbaqayTVFEA6/tYumdF0UmybY0KHyKwGPBXnyckFlrrKdhWFrL3a2HIPXHjht5ZOElKGcXfD2D63P36btb+ww==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -2661,6 +2835,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2928,6 +3111,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2950,6 +3142,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-0.1.2.tgz", @@ -2958,6 +3156,21 @@ "optional": true, "peer": true }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -3218,6 +3431,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -3471,6 +3693,34 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3488,6 +3738,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -3593,6 +3855,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -4753,6 +5024,45 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4987,6 +5297,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5048,6 +5373,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5240,6 +5583,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -5353,6 +5711,28 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5368,6 +5748,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5670,12 +6071,54 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -6616,6 +7059,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7111,6 +7572,18 @@ "node": ">=0.10.0" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7156,9 +7629,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/safe-regex-test": { "version": "1.0.3", @@ -7187,7 +7658,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7599,6 +8069,15 @@ "node": ">=18" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -7916,6 +8395,24 @@ "optional": true, "peer": true }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -8048,6 +8545,21 @@ "node": ">=0.10.0" } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xtend": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", diff --git a/Website/package.json b/Website/package.json index 3db685d..23e0ec7 100644 --- a/Website/package.json +++ b/Website/package.json @@ -11,6 +11,7 @@ "prepipeline": "node scripts/copyStub.js" }, "dependencies": { + "@azure/identity": "^4.13.0", "@joint/core": "^4.1.3", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", @@ -23,6 +24,7 @@ "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "html2canvas": "^1.4.1", "jose": "^5.9.6", "libavoid-js": "^0.4.5", "next": "^15.5.3", diff --git a/Website/public/AzureDevOps.svg b/Website/public/AzureDevOps.svg new file mode 100644 index 0000000..c46851c --- /dev/null +++ b/Website/public/AzureDevOps.svg @@ -0,0 +1 @@ +Icon-devops-261 \ No newline at end of file diff --git a/azure-pipelines-build-jobs.yml b/azure-pipelines-build-jobs.yml index fa3fca2..8d5d901 100644 --- a/azure-pipelines-build-jobs.yml +++ b/azure-pipelines-build-jobs.yml @@ -67,7 +67,6 @@ steps: workingDirectory: $(Build.SourcesDirectory)/Website displayName: "Build Next.js app" - - task: ArchiveFiles@2 inputs: rootFolderOrFile: "$(Build.SourcesDirectory)/Website/.next/standalone" diff --git a/azure-pipelines-deploy-jobs.yml b/azure-pipelines-deploy-jobs.yml index b86e717..58cce5e 100644 --- a/azure-pipelines-deploy-jobs.yml +++ b/azure-pipelines-deploy-jobs.yml @@ -23,6 +23,15 @@ parameters: - name: websiteName type: string default: '' + - name: adoOrganizationUrl + type: string + default: '' + - name: adoProjectName + type: string + default: '' + - name: adoRepositoryName + type: string + default: '' steps: - task: AzureCLI@2 @@ -32,10 +41,46 @@ steps: scriptType: "pscore" scriptLocation: "inlineScript" inlineScript: | + # Create resource group az group create --name ${{ parameters.azureResourceGroupName }} --location ${{ parameters.azureLocation }} - $jsonResult = az deployment group create --resource-group ${{ parameters.azureResourceGroupName }} --template-file ${{ parameters.bicepTemplateFile }} --parameters websitePassword=${{ parameters.websitePassword }} --parameters sessionSecret=${{ parameters.websiteSessionSecret }} --parameters solutionId=${{ parameters.websiteName }} | ConvertFrom-Json + + # Deploy bicep template + $jsonResult = az deployment group create ` + --resource-group ${{ parameters.azureResourceGroupName }} ` + --template-file ${{ parameters.bicepTemplateFile }} ` + --parameters websitePassword="${{ parameters.websitePassword }}" ` + --parameters sessionSecret="${{ parameters.websiteSessionSecret }}" ` + --parameters solutionId="${{ parameters.websiteName }}" ` + --parameters adoOrganizationUrl="${{ parameters.adoOrganizationUrl }}" ` + --parameters adoProjectName="${{ parameters.adoProjectName }}" ` + --parameters adoRepositoryName="${{ parameters.adoRepositoryName }}" ` + | ConvertFrom-Json + + # Extract outputs $webAppName = $jsonResult.properties.outputs.webAppName.value + $principalId = $jsonResult.properties.outputs.managedIdentityPrincipalId.value + $webAppUrl = $jsonResult.properties.outputs.webAppUrl.value + + # Set pipeline variables Write-Host "##vso[task.setvariable variable=webAppName]$webAppName" + Write-Host "##vso[task.setvariable variable=managedIdentityPrincipalId]$principalId" + Write-Host "##vso[task.setvariable variable=webAppUrl]$webAppUrl" + + # Output for manual ADO setup + Write-Host "==================================================" + Write-Host "MANAGED IDENTITY SETUP REQUIRED:" + Write-Host "==================================================" + Write-Host "Web App: $webAppName" + Write-Host "Managed Identity Principal ID: $principalId" + Write-Host "Web App URL: $webAppUrl" + Write-Host "" + Write-Host "MANUAL STEPS REQUIRED:" + Write-Host "1. Go to Azure DevOps Organization Settings > Users" + Write-Host "2. Add user with Principal ID: $principalId" + Write-Host "3. Grant 'Basic' access level" + Write-Host "4. Add to project '${{ parameters.adoProjectName }}' with appropriate permissions" + Write-Host "5. Grant repository access to '${{ parameters.adoRepositoryName }}'" + Write-Host "==================================================" - download: current artifact: WebApp @@ -46,3 +91,25 @@ steps: appType: "webAppLinux" appName: $(webAppName) package: "$(Pipeline.Workspace)/WebApp/WebApp.zip" + + - task: AzureCLI@2 + displayName: "Verify Managed Identity" + inputs: + azureSubscription: ${{ parameters.azureServiceConnectionName }} + scriptType: "pscore" + scriptLocation: "inlineScript" + inlineScript: | + # Test if managed identity can get tokens + Write-Host "Testing Managed Identity token acquisition..." + + # Get token using managed identity (this tests if it's working) + try { + $token = az account get-access-token --resource "https://app.vssps.visualstudio.com/" --query accessToken --output tsv + if ($token) { + Write-Host "Your managed identity is working" + } else { + Write-Host "Failed to acquire token" + } + } catch { + Write-Host "Error testing managed identity: $_" + } diff --git a/azure-pipelines-external.yml b/azure-pipelines-external.yml index 6c55dce..3ecd784 100644 --- a/azure-pipelines-external.yml +++ b/azure-pipelines-external.yml @@ -20,6 +20,9 @@ # - AzureClientId # - AzureClientSecret # - DataverseUrl +# - AdoWikiName +# - AdoWikiPagePath +# - AdoRepositoryName trigger: none pr: none @@ -88,4 +91,7 @@ stages: azureLocation: $(AzureLocation) websitePassword: $(WebsitePassword) websiteSessionSecret: $(WebsiteSessionSecret) - websiteName: $(WebsiteName) \ No newline at end of file + websiteName: $(WebsiteName) + adoOrganizationUrl: $(System.CollectionUri) + adoProjectName: $(System.TeamProject) + adoRepositoryName: $(AdoRepositoryName) \ No newline at end of file