From 9118406cae1078ed7638ebf7dfe27bfdb895e5ce Mon Sep 17 00:00:00 2001 From: boer Date: Tue, 24 Jun 2025 17:40:49 +0200 Subject: [PATCH 01/45] chore: react-router installation, jointjs installation, nextjs upgrade to remove DDoS vulnerability --- Website/package-lock.json | 588 +++++++++++++++++++++++--------------- Website/package.json | 8 +- 2 files changed, 369 insertions(+), 227 deletions(-) diff --git a/Website/package-lock.json b/Website/package-lock.json index ecf4c00..b009935 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -8,6 +8,7 @@ "name": "website", "version": "0.1.0", "dependencies": { + "@joint/core": "^4.1.3", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", @@ -21,9 +22,10 @@ "clsx": "^2.1.1", "jose": "^5.9.6", "lucide-react": "^0.462.0", - "next": "^15.0.3", - "react": "19.0.0-rc-66855b96-20241106", - "react-dom": "19.0.0-rc-66855b96-20241106", + "next": "^15.2.2", + "react": "^19.1.0", + "react-dom": "19.1.0", + "react-router": "^7.6.2", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7" }, @@ -50,9 +52,10 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -134,6 +137,19 @@ "@floating-ui/utils": "^0.2.9" } }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.3.tgz", + "integrity": "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@floating-ui/utils": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", @@ -175,12 +191,13 @@ "dev": true }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz", + "integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -192,16 +209,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.1.0" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz", + "integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -213,16 +231,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.1.0" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -232,12 +251,13 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", + "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -247,12 +267,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", + "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", "cpu": [ "arm" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -262,12 +283,29 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", + "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", + "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -277,12 +315,13 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", + "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", "cpu": [ "s390x" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -292,12 +331,13 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", + "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -307,12 +347,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", + "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -322,12 +363,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", + "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -337,12 +379,13 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz", + "integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==", "cpu": [ "arm" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -354,16 +397,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.1.0" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz", + "integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -375,16 +419,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.1.0" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz", + "integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==", "cpu": [ "s390x" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -396,16 +441,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.1.0" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz", + "integrity": "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -417,16 +463,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.1.0" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz", + "integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -438,16 +485,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz", + "integrity": "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -459,19 +507,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.1.0" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz", + "integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==", "cpu": [ "wasm32" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.4.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -480,13 +529,33 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz", + "integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz", + "integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==", "cpu": [ "ia32" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -499,12 +568,13 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz", + "integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==", "cpu": [ "x64" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -557,6 +627,12 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@joint/core": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@joint/core/-/core-4.1.3.tgz", + "integrity": "sha512-X769blCoVxtx6NNm/cbHDXDOa+Gt7eZwrJMLnqJw8c5NkjmcYWCY1kA3ep8RfRRVG76f3QLNd9a8Q/aItI/WWw==", + "license": "MPL-2.0" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -601,9 +677,10 @@ } }, "node_modules/@next/env": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.3.tgz", - "integrity": "sha512-t9Xy32pjNOvVn2AS+Utt6VmyrshbpfUMhIjFO60gI58deSo/KgLOp31XZ4O+kY/Is8WAGYwA5gR7kOb1eORDBA==" + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.4.tgz", + "integrity": "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ==", + "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { "version": "15.0.3", @@ -615,12 +692,13 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.3.tgz", - "integrity": "sha512-s3Q/NOorCsLYdCKvQlWU+a+GeAd3C8Rb3L1YnetsgwXzhc3UTWrtQpB/3eCjFOdGUj5QmXfRak12uocd1ZiiQw==", + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.4.tgz", + "integrity": "sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -630,12 +708,13 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.3.tgz", - "integrity": "sha512-Zxl/TwyXVZPCFSf0u2BNj5sE0F2uR6iSKxWpq4Wlk/Sv9Ob6YCKByQTkV2y6BCic+fkabp9190hyrDdPA/dNrw==", + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.4.tgz", + "integrity": "sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -645,12 +724,13 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.3.tgz", - "integrity": "sha512-T5+gg2EwpsY3OoaLxUIofmMb7ohAUlcNZW0fPQ6YAutaWJaxt1Z1h+8zdl4FRIOr5ABAAhXtBcpkZNwUcKI2fw==", + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.4.tgz", + "integrity": "sha512-l8ZQOCCg7adwmsnFm8m5q9eIPAHdaB2F3cxhufYtVo84pymwKuWfpYTKcUiFcutJdp9xGHC+F1Uq3xnFU1B/7g==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -660,12 +740,13 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.3.tgz", - "integrity": "sha512-WkAk6R60mwDjH4lG/JBpb2xHl2/0Vj0ZRu1TIzWuOYfQ9tt9NFsIinI1Epma77JVgy81F32X/AeD+B2cBu/YQA==", + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.4.tgz", + "integrity": "sha512-wFyZ7X470YJQtpKot4xCY3gpdn8lE9nTlldG07/kJYexCUpX1piX+MBfZdvulo+t1yADFVEuzFfVHfklfEx8kw==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -675,12 +756,13 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.0.3.tgz", - "integrity": "sha512-gWL/Cta1aPVqIGgDb6nxkqy06DkwJ9gAnKORdHWX1QBbSZZB+biFYPFti8aKIQL7otCE1pjyPaXpFzGeG2OS2w==", + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.4.tgz", + "integrity": "sha512-gEbH9rv9o7I12qPyvZNVTyP/PWKqOp8clvnoYZQiX800KkqsaJZuOXkWgMa7ANCCh/oEN2ZQheh3yH8/kWPSEg==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -690,12 +772,13 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.0.3.tgz", - "integrity": "sha512-QQEMwFd8r7C0GxQS62Zcdy6GKx999I/rTO2ubdXEe+MlZk9ZiinsrjwoiBL5/57tfyjikgh6GOU2WRQVUej3UA==", + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.4.tgz", + "integrity": "sha512-Cf8sr0ufuC/nu/yQ76AnarbSAXcwG/wj+1xFPNbyNo8ltA6kw5d5YqO8kQuwVIxk13SBdtgXrNyom3ZosHAy4A==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -705,12 +788,13 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.3.tgz", - "integrity": "sha512-9TEp47AAd/ms9fPNgtgnT7F3M1Hf7koIYYWCMQ9neOwjbVWJsHZxrFbI3iEDJ8rf1TDGpmHbKxXf2IFpAvheIQ==", + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.4.tgz", + "integrity": "sha512-ay5+qADDN3rwRbRpEhTOreOn1OyJIXS60tg9WMYTWCy3fB6rGoyjLVxc4dR9PYjEdR2iDYsaF5h03NA+XuYPQQ==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -720,12 +804,13 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.3.tgz", - "integrity": "sha512-VNAz+HN4OGgvZs6MOoVfnn41kBzT+M+tB+OK4cww6DNyWS6wKaDpaAm/qLeOUbnMh0oVx1+mg0uoYARF69dJyA==", + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.4.tgz", + "integrity": "sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -799,6 +884,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, @@ -821,6 +907,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -835,6 +922,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.1.2" }, @@ -857,6 +945,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, @@ -1123,6 +1212,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", @@ -1163,6 +1253,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", + "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-primitive": "2.0.0", @@ -1281,6 +1372,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", @@ -1307,6 +1399,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", @@ -1331,6 +1424,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -1354,6 +1448,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -1377,6 +1472,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.1.2" }, @@ -1416,6 +1512,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.2", @@ -1443,22 +1540,11 @@ } } }, - "node_modules/@radix-ui/react-popper/node_modules/@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -1473,6 +1559,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.1.2" }, @@ -1495,6 +1582,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, @@ -1512,6 +1600,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -1535,6 +1624,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -1558,6 +1648,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.1.0" }, @@ -1970,19 +2061,6 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper/node_modules/@floating-ui/react-dom": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.3.tgz", - "integrity": "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", @@ -2151,29 +2229,6 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-select/node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", @@ -2458,6 +2513,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", @@ -2484,6 +2540,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -2507,6 +2564,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -2530,6 +2588,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.1.2" }, @@ -2565,6 +2624,29 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -2679,6 +2761,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", "dependencies": { "@radix-ui/rect": "1.1.0" }, @@ -2696,6 +2779,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, @@ -2710,11 +2794,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", - "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.2" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2732,9 +2817,10 @@ } }, "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2746,11 +2832,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", - "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.1.2" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2768,11 +2855,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", - "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2787,7 +2875,8 @@ "node_modules/@radix-ui/rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -2807,11 +2896,12 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "node_modules/@swc/helpers": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", - "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@types/json5": { @@ -3591,6 +3681,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", "optional": true, "dependencies": { "color-convert": "^2.0.1", @@ -3620,6 +3711,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", "optional": true, "dependencies": { "color-name": "^1.0.0", @@ -3640,6 +3732,15 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3785,9 +3886,10 @@ } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", "optional": true, "engines": { "node": ">=8" @@ -4972,6 +5074,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", "optional": true }, "node_modules/is-async-function": { @@ -5646,13 +5749,14 @@ "dev": true }, "node_modules/next": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/next/-/next-15.0.3.tgz", - "integrity": "sha512-ontCbCRKJUIoivAdGB34yCaOcPgYXr9AAkV/IwqFfWWTXEPUgLYkSkqBhIk9KK7gGmgjc64B+RdoeIDM13Irnw==", + "version": "15.3.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.3.4.tgz", + "integrity": "sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==", + "license": "MIT", "dependencies": { - "@next/env": "15.0.3", + "@next/env": "15.3.4", "@swc/counter": "0.1.3", - "@swc/helpers": "0.5.13", + "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -5665,22 +5769,22 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.0.3", - "@next/swc-darwin-x64": "15.0.3", - "@next/swc-linux-arm64-gnu": "15.0.3", - "@next/swc-linux-arm64-musl": "15.0.3", - "@next/swc-linux-x64-gnu": "15.0.3", - "@next/swc-linux-x64-musl": "15.0.3", - "@next/swc-win32-arm64-msvc": "15.0.3", - "@next/swc-win32-x64-msvc": "15.0.3", - "sharp": "^0.33.5" + "@next/swc-darwin-arm64": "15.3.4", + "@next/swc-darwin-x64": "15.3.4", + "@next/swc-linux-arm64-gnu": "15.3.4", + "@next/swc-linux-arm64-musl": "15.3.4", + "@next/swc-linux-x64-gnu": "15.3.4", + "@next/swc-linux-x64-musl": "15.3.4", + "@next/swc-win32-arm64-msvc": "15.3.4", + "@next/swc-win32-x64-msvc": "15.3.4", + "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-66855b96-20241106", - "react-dom": "^18.2.0 || 19.0.0-rc-66855b96-20241106", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "peerDependenciesMeta": { @@ -6207,22 +6311,24 @@ ] }, "node_modules/react": { - "version": "19.0.0-rc-66855b96-20241106", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0-rc-66855b96-20241106.tgz", - "integrity": "sha512-klH7xkT71SxRCx4hb1hly5FJB21Hz0ACyxbXYAECEqssUjtJeFUAaI2U1DgJAzkGEnvEm3DkxuBchMC/9K4ipg==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.0.0-rc-66855b96-20241106", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc-66855b96-20241106.tgz", - "integrity": "sha512-D25vdaytZ1wFIRiwNU98NPQ/upS2P8Co4/oNoa02PzHbh8deWdepjm5qwZM/46OdSiGv4WSWwxP55RO9obqJEQ==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", "dependencies": { - "scheduler": "0.25.0-rc-66855b96-20241106" + "scheduler": "^0.26.0" }, "peerDependencies": { - "react": "19.0.0-rc-66855b96-20241106" + "react": "^19.1.0" } }, "node_modules/react-is": { @@ -6276,6 +6382,28 @@ } } }, + "node_modules/react-router": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", + "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -6472,15 +6600,17 @@ } }, "node_modules/scheduler": { - "version": "0.25.0-rc-66855b96-20241106", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-66855b96-20241106.tgz", - "integrity": "sha512-HQXp/Mnp/MMRSXMQF7urNFla+gmtXW/Gr1KliuR0iboTit4KvZRY8KYaq5ccCTAOJiUqQh2rE2F3wgUekmgdlA==" + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "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" }, @@ -6488,6 +6618,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6521,15 +6657,16 @@ } }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.2.tgz", + "integrity": "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==", "hasInstallScript": true, + "license": "Apache-2.0", "optional": true, "dependencies": { "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "detect-libc": "^2.0.4", + "semver": "^7.7.2" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -6538,25 +6675,27 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.34.2", + "@img/sharp-darwin-x64": "0.34.2", + "@img/sharp-libvips-darwin-arm64": "1.1.0", + "@img/sharp-libvips-darwin-x64": "1.1.0", + "@img/sharp-libvips-linux-arm": "1.1.0", + "@img/sharp-libvips-linux-arm64": "1.1.0", + "@img/sharp-libvips-linux-ppc64": "1.1.0", + "@img/sharp-libvips-linux-s390x": "1.1.0", + "@img/sharp-libvips-linux-x64": "1.1.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", + "@img/sharp-libvips-linuxmusl-x64": "1.1.0", + "@img/sharp-linux-arm": "0.34.2", + "@img/sharp-linux-arm64": "0.34.2", + "@img/sharp-linux-s390x": "0.34.2", + "@img/sharp-linux-x64": "0.34.2", + "@img/sharp-linuxmusl-arm64": "0.34.2", + "@img/sharp-linuxmusl-x64": "0.34.2", + "@img/sharp-wasm32": "0.34.2", + "@img/sharp-win32-arm64": "0.34.2", + "@img/sharp-win32-ia32": "0.34.2", + "@img/sharp-win32-x64": "0.34.2" } }, "node_modules/shebang-command": { @@ -6611,6 +6750,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", "optional": true, "dependencies": { "is-arrayish": "^0.3.1" diff --git a/Website/package.json b/Website/package.json index 25afdde..6c3d917 100644 --- a/Website/package.json +++ b/Website/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@joint/core": "^4.1.3", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", @@ -22,9 +23,10 @@ "clsx": "^2.1.1", "jose": "^5.9.6", "lucide-react": "^0.462.0", - "next": "^15.0.3", - "react": "19.0.0-rc-66855b96-20241106", - "react-dom": "19.0.0-rc-66855b96-20241106", + "next": "^15.2.2", + "react": "^19.1.0", + "react-dom": "19.1.0", + "react-router": "^7.6.2", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7" }, From 2adc6bbca25465e95e4cde2853c90e8df80475c3 Mon Sep 17 00:00:00 2001 From: boer Date: Tue, 24 Jun 2025 21:03:11 +0200 Subject: [PATCH 02/45] chore: nexjs routing instead of client side react router. Initial work to Datamodelview page --- Website/app/diagram/page.tsx | 3 + Website/app/page.tsx | 9 +- .../diagram/entity/EntityAttribute.ts | 13 +++ .../components/diagram/entity/EntityBody.ts | 35 +++++++ Website/components/diagram/entity/entity.ts | 91 +++++++++++++++++++ Website/package-lock.json | 38 -------- Website/package.json | 1 - .../{components => routes}/DatamodelView.tsx | 10 +- Website/routes/DiagramView.tsx | 48 ++++++++++ 9 files changed, 198 insertions(+), 50 deletions(-) create mode 100644 Website/app/diagram/page.tsx create mode 100644 Website/components/diagram/entity/EntityAttribute.ts create mode 100644 Website/components/diagram/entity/EntityBody.ts create mode 100644 Website/components/diagram/entity/entity.ts rename Website/{components => routes}/DatamodelView.tsx (80%) create mode 100644 Website/routes/DiagramView.tsx diff --git a/Website/app/diagram/page.tsx b/Website/app/diagram/page.tsx new file mode 100644 index 0000000..a280a19 --- /dev/null +++ b/Website/app/diagram/page.tsx @@ -0,0 +1,3 @@ +"use client"; +import DiagramView from "@/routes/DiagramView"; +export default DiagramView; \ No newline at end of file diff --git a/Website/app/page.tsx b/Website/app/page.tsx index ad23133..0cb66be 100644 --- a/Website/app/page.tsx +++ b/Website/app/page.tsx @@ -1,9 +1,6 @@ -import { DatamodelView } from "@/components/DatamodelView"; -import { Loading } from "@/components/ui/loading"; -import { Suspense } from "react"; +"use client"; +import { DatamodelView } from "@/routes/DatamodelView"; export default function Home() { - return }> - - + return ; } diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/entity/EntityAttribute.ts new file mode 100644 index 0000000..bd0b2ff --- /dev/null +++ b/Website/components/diagram/entity/EntityAttribute.ts @@ -0,0 +1,13 @@ +import { AttributeType } from "@/lib/Types"; + +interface IEntityAttribute { + attribute: AttributeType; +} + +export const EntityAttribute = ({ attribute }: IEntityAttribute): string => { + return ` +
+

${attribute.DisplayName}

+
+ `; +} \ No newline at end of file diff --git a/Website/components/diagram/entity/EntityBody.ts b/Website/components/diagram/entity/EntityBody.ts new file mode 100644 index 0000000..f07f1a6 --- /dev/null +++ b/Website/components/diagram/entity/EntityBody.ts @@ -0,0 +1,35 @@ +import { AttributeType, EntityType } from '@/lib/Types' +import { EntityAttribute } from './EntityAttribute'; + +interface IEntityBody { + entity: EntityType; + visibleItems: AttributeType[]; +} + +export function EntityBody({ entity, visibleItems }: IEntityBody): string { + + const icon = entity.IconBase64 != null + ? `data:image/svg+xml;base64,${entity.IconBase64}` + : '/vercel.svg'; + + return ` +
+ + +
+
+ +
+
+

${entity.DisplayName}

+

${entity.SchemaName}

+
+
+ + +
+ ${visibleItems.map(attribute => (EntityAttribute({ attribute }))).join('')} +
+
+ `; +} diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts new file mode 100644 index 0000000..19af5b2 --- /dev/null +++ b/Website/components/diagram/entity/entity.ts @@ -0,0 +1,91 @@ +import { AttributeType, EntityType } from '@/lib/Types'; +import { dia, util } from '@joint/core'; +import { EntityBody } from './EntityBody'; + +export class EntityElement extends dia.Element { + + initialize(...args: any[]) { + super.initialize(...args); + const entity = this.get('data'); + if (entity) this.updateAttributes(entity); + } + + updateAttributes(entity: EntityType) { + const itemHeight = 32; + const headerHeight = 80; + const maxHeight = this.get('size')!.height; + const availableHeight = maxHeight - headerHeight; + const maxVisibleItems = Math.floor(availableHeight / itemHeight); + // thoughts only custom no MS lookups. Then we can add functionality to add extra lookups + console.log(entity.Relationships.filter(r => !r.Name.startsWith("regarding") + && !r.Name.startsWith("object") + && !r.Name.startsWith("record") + && !r.Name.startsWith("msa") + && !r.Name.startsWith("master"))) + const visibleItems = entity.Attributes.filter(attr => attr.AttributeType === "LookupAttribute" && attr.Targets.every(t => t.IsInSolution)).slice(0, maxVisibleItems); + + const html = EntityBody({ entity, visibleItems }); + + // links for relationships + const itemAttrs: any = {}; + visibleItems.forEach((_, i) => { + itemAttrs[`port-${i}`] = { + ref: 'body', + refX: '100%', + refY: 80 + i * itemHeight + itemHeight / 2, + xAlignment: 'middle', + yAlignment: 'middle', + magnet: true, + r: 6, + fill: '#2563eb', + stroke: '#1e40af' + }; + }); + + this.set('attrs', { + ...this.get('attrs'), + fo: { + refWidth: '100%', + refHeight: '100%', + html + }, + ...itemAttrs + }); + + // Markup + const baseMarkup = [ + { tagName: 'rect', selector: 'body' }, + { + tagName: 'foreignObject', + selector: 'fo' + }, + ...visibleItems.map((_, i) => ({ + tagName: 'circle', + selector: `port-${i}` + })) + ]; + + this.set('markup', baseMarkup); + } + + 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/package-lock.json b/Website/package-lock.json index b009935..5d3db3b 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -25,7 +25,6 @@ "next": "^15.2.2", "react": "^19.1.0", "react-dom": "19.1.0", - "react-router": "^7.6.2", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7" }, @@ -3732,15 +3731,6 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, - "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6382,28 +6372,6 @@ } } }, - "node_modules/react-router": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", - "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -6618,12 +6586,6 @@ "node": ">=10" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "license": "MIT" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/Website/package.json b/Website/package.json index 6c3d917..0e3088d 100644 --- a/Website/package.json +++ b/Website/package.json @@ -26,7 +26,6 @@ "next": "^15.2.2", "react": "^19.1.0", "react-dom": "19.1.0", - "react-router": "^7.6.2", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7" }, diff --git a/Website/components/DatamodelView.tsx b/Website/routes/DatamodelView.tsx similarity index 80% rename from Website/components/DatamodelView.tsx rename to Website/routes/DatamodelView.tsx index 361afcd..19e6bec 100644 --- a/Website/components/DatamodelView.tsx +++ b/Website/routes/DatamodelView.tsx @@ -2,11 +2,11 @@ import { useSearchParams } from "next/navigation"; import { useState, useEffect } from "react"; -import { AppSidebar } from "./AppSiderbar"; -import List from "./List"; -import { TooltipProvider } from "./ui/tooltip"; -import { TouchProvider } from "./ui/hybridtooltop"; -import { SidebarTrigger, useSidebar } from "./ui/sidebar"; +import { AppSidebar } from "../components/AppSiderbar"; +import List from "../components/List"; +import { TooltipProvider } from "../components/ui/tooltip"; +import { TouchProvider } from "../components/ui/hybridtooltop"; +import { SidebarTrigger, useSidebar } from "../components/ui/sidebar"; export function DatamodelView() { const [selected, setSelected] = useState(null); diff --git a/Website/routes/DiagramView.tsx b/Website/routes/DiagramView.tsx new file mode 100644 index 0000000..41db19f --- /dev/null +++ b/Website/routes/DiagramView.tsx @@ -0,0 +1,48 @@ +'use client'; + +import React, { useEffect, useRef } from 'react' +import { dia } from '@joint/core' +import { Groups } from "../generated/Data" +import { EntityElement } from '@/components/diagram/entity/entity'; +import { Link } from 'lucide-react'; +import { title } from 'process'; + +interface IDiagramView {} + +export default function DiagramView({ }: IDiagramView) { + const paperRef = useRef(null); + + const subSet = ["account"]//,"contact","opportunity","systemuser","team"] + const entityData = Groups[1].Entities.filter(e => subSet.includes(e.SchemaName.toLowerCase())); + + useEffect(() => { + if (!paperRef.current) return; + const graph = new dia.Graph(); + const paper = new dia.Paper({ + el: paperRef.current, + model: graph, + gridSize: 10, + background: { color: '#F8F9FA' }, + }); + + // Entities + for (const entity of entityData) { + + const entityElement = new EntityElement({ + position: { x: 50, y: 50 }, + data: entity + }); + entityElement.addTo(graph); + } + + return () => { + paper.remove(); + }; + }, [paperRef.current]); + + return ( + <> +
+ + ); +} From 490d0600918380cbae72d0b7a170f3308226e454 Mon Sep 17 00:00:00 2001 From: boer Date: Thu, 26 Jun 2025 18:16:03 +0200 Subject: [PATCH 03/45] chore: first draft of links (cant seem to make the router work though) --- Generator/DTO/Attributes/Attribute.cs | 2 + Generator/DTO/Attributes/LookupAttribute.cs | 2 +- Generator/DTO/Relationship.cs | 8 +- Generator/DataverseService.cs | 6 +- .../diagram/entity/EntityAttribute.ts | 5 +- .../components/diagram/entity/EntityBody.ts | 5 +- Website/components/diagram/entity/entity.ts | 106 +++++++++++------- Website/lib/Types.ts | 2 + Website/routes/DiagramView.tsx | 90 ++++++++++++++- 9 files changed, 174 insertions(+), 52 deletions(-) diff --git a/Generator/DTO/Attributes/Attribute.cs b/Generator/DTO/Attributes/Attribute.cs index 45004bb..dd4db8c 100644 --- a/Generator/DTO/Attributes/Attribute.cs +++ b/Generator/DTO/Attributes/Attribute.cs @@ -4,6 +4,7 @@ namespace Generator.DTO.Attributes; public abstract class Attribute { + public bool IsManaged { get; } public string DisplayName { get; } public string SchemaName { get; } public string Description { get; } @@ -15,6 +16,7 @@ public abstract class Attribute protected Attribute(AttributeMetadata metadata) { + IsManaged = metadata.IsManaged ?? false; DisplayName = metadata.DisplayName.UserLocalizedLabel?.Label ?? string.Empty; SchemaName = metadata.SchemaName; Description = metadata.Description.UserLocalizedLabel?.Label.PrettyDescription() ?? string.Empty; diff --git a/Generator/DTO/Attributes/LookupAttribute.cs b/Generator/DTO/Attributes/LookupAttribute.cs index 9b5e6f8..198fae7 100644 --- a/Generator/DTO/Attributes/LookupAttribute.cs +++ b/Generator/DTO/Attributes/LookupAttribute.cs @@ -20,7 +20,7 @@ public LookupAttribute(LookupAttributeMetadata metadata, Dictionary !logicalToSchema.ContainsKey(target)) + .Where(logicalToSchema.ContainsKey) .Select(target => logicalToSchema[target]); } } diff --git a/Generator/DTO/Relationship.cs b/Generator/DTO/Relationship.cs index dd1656b..1ff6fc6 100644 --- a/Generator/DTO/Relationship.cs +++ b/Generator/DTO/Relationship.cs @@ -2,10 +2,12 @@ namespace Generator.DTO; + public record Relationship( - string Name, - string TableSchema, - string LookupDisplayName, + bool IsManaged, + string Name, + string TableSchema, + string LookupDisplayName, string RelationshipSchema, bool IsManyToMany, CascadeConfiguration? CascadeConfiguration); diff --git a/Generator/DataverseService.cs b/Generator/DataverseService.cs index feb4ce0..3a842d2 100644 --- a/Generator/DataverseService.cs +++ b/Generator/DataverseService.cs @@ -151,8 +151,9 @@ private static Record MakeRecord( .ToList(); var oneToMany = (entity.OneToManyRelationships ?? Enumerable.Empty()) - .Where(x => logicalToSchema.ContainsKey(x.ReferencingEntity) && attributeLogicalToSchema[x.ReferencingEntity].ContainsKey(x.ReferencingAttribute)) + .Where(x => logicalToSchema.ContainsKey(x.ReferencingEntity) && logicalToSchema[x.ReferencingEntity].IsInSolution && attributeLogicalToSchema[x.ReferencingEntity].ContainsKey(x.ReferencingAttribute)) .Select(x => new DTO.Relationship( + x.IsManaged ?? false, x.ReferencingEntityNavigationPropertyName, logicalToSchema[x.ReferencingEntity].Name, attributeLogicalToSchema[x.ReferencingEntity][x.ReferencingAttribute], @@ -162,8 +163,9 @@ private static Record MakeRecord( .ToList(); var manyToMany = relevantManyToMany - .Where(x => logicalToSchema.ContainsKey(x.Entity1LogicalName)) + .Where(x => logicalToSchema.ContainsKey(x.Entity1LogicalName) && logicalToSchema[x.Entity1LogicalName].IsInSolution) .Select(x => new DTO.Relationship( + x.IsManaged ?? false, x.Entity1AssociatedMenuConfiguration.Behavior == AssociatedMenuBehavior.UseLabel ? x.Entity1AssociatedMenuConfiguration.Label.UserLocalizedLabel?.Label ?? x.Entity1NavigationPropertyName : x.Entity1NavigationPropertyName, diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/entity/EntityAttribute.ts index bd0b2ff..9308211 100644 --- a/Website/components/diagram/entity/EntityAttribute.ts +++ b/Website/components/diagram/entity/EntityAttribute.ts @@ -5,8 +5,11 @@ interface IEntityAttribute { } export const EntityAttribute = ({ attribute }: IEntityAttribute): string => { + const portId = `port-${attribute.SchemaName.toLowerCase()}`; return ` -
+

${attribute.DisplayName}

`; diff --git a/Website/components/diagram/entity/EntityBody.ts b/Website/components/diagram/entity/EntityBody.ts index f07f1a6..847dc96 100644 --- a/Website/components/diagram/entity/EntityBody.ts +++ b/Website/components/diagram/entity/EntityBody.ts @@ -17,15 +17,14 @@ export function EntityBody({ entity, visibleItems }: IEntityBody): string {
-
- +
+

${entity.DisplayName}

${entity.SchemaName}

-
${visibleItems.map(attribute => (EntityAttribute({ attribute }))).join('')} diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index 19af5b2..a0c01a1 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -1,6 +1,7 @@ import { AttributeType, EntityType } from '@/lib/Types'; import { dia, util } from '@joint/core'; import { EntityBody } from './EntityBody'; +import Attributes from '@/components/Attributes'; export class EntityElement extends dia.Element { @@ -10,62 +11,91 @@ export class EntityElement extends dia.Element { if (entity) this.updateAttributes(entity); } - updateAttributes(entity: EntityType) { + static getVisibleItemsAndPorts(entity: EntityType) { const itemHeight = 32; const headerHeight = 80; - const maxHeight = this.get('size')!.height; + const maxHeight = 360; // default size const availableHeight = maxHeight - headerHeight; const maxVisibleItems = Math.floor(availableHeight / itemHeight); - // thoughts only custom no MS lookups. Then we can add functionality to add extra lookups - console.log(entity.Relationships.filter(r => !r.Name.startsWith("regarding") - && !r.Name.startsWith("object") - && !r.Name.startsWith("record") - && !r.Name.startsWith("msa") - && !r.Name.startsWith("master"))) - const visibleItems = entity.Attributes.filter(attr => attr.AttributeType === "LookupAttribute" && attr.Targets.every(t => t.IsInSolution)).slice(0, maxVisibleItems); + const visibleItems = [ + { DisplayName: "Key", SchemaName: entity.SchemaName + "id" } as AttributeType, + ...entity.Attributes.filter(attr => + attr.AttributeType === "LookupAttribute" && + !attr.IsManaged && + !attr.SchemaName.startsWith("msdyn") + ) + ].slice(0, maxVisibleItems); + // 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, portMap } = EntityElement.getVisibleItemsAndPorts(entity); const html = EntityBody({ entity, visibleItems }); - // links for relationships - const itemAttrs: any = {}; - visibleItems.forEach((_, i) => { - itemAttrs[`port-${i}`] = { - ref: 'body', - refX: '100%', - refY: 80 + i * itemHeight + itemHeight / 2, - xAlignment: 'middle', - yAlignment: 'middle', - magnet: true, - r: 6, - fill: '#2563eb', - stroke: '#1e40af' + // Markup + const baseMarkup = [ + { tagName: 'rect', selector: 'body' }, + { tagName: 'foreignObject', selector: 'fo' } + ]; + + this.set('markup', baseMarkup); + + const itemHeight = 28; + const itemYSpacing = 8; + const itemXSpacing = 16; + const startY = 80 + itemYSpacing / 2; + + const height = startY + visibleItems.length * (itemHeight + itemYSpacing); + + // Generate ports and SVG attributes + const portItems = visibleItems.map((attr, i) => { + const portId = `port-${attr.SchemaName.toLowerCase()}`; + const y = startY + i * (itemHeight + itemYSpacing); + + return { + id: portId, + group: 'attribute', + args: { x: itemXSpacing, y }, + attrs: { + 'attribute-rect': { + width: 480 - itemXSpacing * 2, + fill: "transparent", + height: itemHeight, + magnet: true + }, + } }; }); + this.set('ports', { + groups: { + attribute: { + position: { + name: 'absolute' + }, + markup: [ + { tagName: 'rect', selector: 'attribute-rect' } + ] + } + }, + items: portItems + }); + this.set('attrs', { ...this.get('attrs'), fo: { refWidth: '100%', refHeight: '100%', html - }, - ...itemAttrs + } }); - // Markup - const baseMarkup = [ - { tagName: 'rect', selector: 'body' }, - { - tagName: 'foreignObject', - selector: 'fo' - }, - ...visibleItems.map((_, i) => ({ - tagName: 'circle', - selector: `port-${i}` - })) - ]; - - this.set('markup', baseMarkup); + this.resize(480, height); } defaults() { diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts index a4712db..0a21c27 100644 --- a/Website/lib/Types.ts +++ b/Website/lib/Types.ts @@ -41,6 +41,7 @@ export const enum CalculationMethods { } export type BaseAttribute = { + IsManaged: boolean, DisplayName: string, SchemaName: string, Description: string | null, @@ -159,6 +160,7 @@ export type CascadeConfigurationType = { } export type RelationshipType = { + IsManaged: boolean, Name: string, TableSchema: string, LookupDisplayName: string, diff --git a/Website/routes/DiagramView.tsx b/Website/routes/DiagramView.tsx index 41db19f..745dcea 100644 --- a/Website/routes/DiagramView.tsx +++ b/Website/routes/DiagramView.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useRef } from 'react' -import { dia } from '@joint/core' +import { dia, routers, shapes } from '@joint/core' import { Groups } from "../generated/Data" import { EntityElement } from '@/components/diagram/entity/entity'; import { Link } from 'lucide-react'; @@ -12,8 +12,8 @@ interface IDiagramView {} export default function DiagramView({ }: IDiagramView) { const paperRef = useRef(null); - const subSet = ["account"]//,"contact","opportunity","systemuser","team"] - const entityData = Groups[1].Entities.filter(e => subSet.includes(e.SchemaName.toLowerCase())); + const entityData = Groups[1].Entities; + console.log(entityData) useEffect(() => { if (!paperRef.current) return; @@ -21,18 +21,100 @@ export default function DiagramView({ }: IDiagramView) { const paper = new dia.Paper({ el: paperRef.current, model: graph, + width: 1920, + height: 1080, gridSize: 10, background: { color: '#F8F9FA' }, }); + // Store entity elements and port maps by SchemaName for easy lookup + const entityMap = new Map(); + // Entities for (const entity of entityData) { - + const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); const entityElement = new EntityElement({ position: { x: 50, y: 50 }, data: entity }); entityElement.addTo(graph); + entityMap.set(entity.SchemaName, { element: entityElement, portMap }); + } + + console.log(entityMap) + + // Create links for lookups + for (const entity of entityData) { + const entityInfo = entityMap.get(entity.SchemaName); + if (!entityInfo) continue; + const { portMap } = entityInfo; + const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); + // Start from index 1 (0 is key) + for (let i = 1; i < visibleItems.length; i++) { + const attr = visibleItems[i]; + if (attr.AttributeType !== "LookupAttribute") continue; + // For each target entity in the lookup + 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`]; + const sourceId = entityInfo.element.id; + const targetId = targetInfo.element.id; + + if (!sourcePort || !targetPort) { + console.warn("Missing port:", { sourcePort, targetPort, attr, target }); + continue; + } + if (!sourceId || !targetId) { + console.warn("Missing element id:", { sourceId, targetId, entityInfo, targetInfo }); + continue; + } + + const link = new shapes.standard.Link({ + source: { id: entityInfo.element.id, port: sourcePort }, + target: { id: targetInfo.element.id, port: targetPort }, + router: { + name: 'rightAngle', + args: { + margin: 32, + sourceDirection: routers.rightAngle.Directions.RIGHT, + targetDirection: routers.rightAngle.Directions.LEFT + } + }, + connector: { name: 'rounded' }, + attrs: { + line: { + stroke: '#6366f1', + strokeWidth: 2, + targetMarker: { + 'type': 'path', + 'd': 'M 10 -5 L 0 0 L 10 5 Z', + 'fill': '#6366f1', + 'stroke': '#6366f1', + } + } + }, + labels: [ + { + position: 0.05, + attrs: { + text: { text: '*', fontSize: 18, fill: '#6366f1', fontWeight: 'bold' }, + rect: { fill: 'white', stroke: 'none' } + } + }, + { + position: 0.95, + attrs: { + text: { text: '+', fontSize: 18, fill: '#6366f1', fontWeight: 'bold' }, + rect: { fill: 'white', stroke: 'none' } + } + } + ] + }); + link.addTo(graph); + } + } } return () => { From 192e141e0e46e4b1d297fea7de1cc14432442477 Mon Sep 17 00:00:00 2001 From: boer Date: Fri, 27 Jun 2025 16:09:14 +0200 Subject: [PATCH 04/45] chore: addition work on digram page --- .../diagram/entity/EntityAttribute.ts | 14 +- .../components/diagram/entity/EntityBody.ts | 2 +- Website/components/diagram/entity/entity.ts | 79 +++++-- Website/package-lock.json | 15 ++ Website/package.json | 2 + Website/routes/DiagramView.tsx | 207 +++++++++++------- 6 files changed, 213 insertions(+), 106 deletions(-) diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/entity/EntityAttribute.ts index 9308211..55cb384 100644 --- a/Website/components/diagram/entity/EntityAttribute.ts +++ b/Website/components/diagram/entity/EntityAttribute.ts @@ -2,15 +2,17 @@ import { AttributeType } from "@/lib/Types"; interface IEntityAttribute { attribute: AttributeType; + isKey: boolean; } -export const EntityAttribute = ({ attribute }: IEntityAttribute): string => { - const portId = `port-${attribute.SchemaName.toLowerCase()}`; +export const EntityAttribute = ({ attribute, isKey }: IEntityAttribute): string => { return ` -

${attribute.DisplayName}

-
+ `; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/Website/components/diagram/entity/EntityBody.ts b/Website/components/diagram/entity/EntityBody.ts index 847dc96..0f631a1 100644 --- a/Website/components/diagram/entity/EntityBody.ts +++ b/Website/components/diagram/entity/EntityBody.ts @@ -27,7 +27,7 @@ export function EntityBody({ entity, visibleItems }: IEntityBody): string {
- ${visibleItems.map(attribute => (EntityAttribute({ attribute }))).join('')} + ${visibleItems.map((attribute, i) => (EntityAttribute({ attribute, isKey: i == 0 }))).join('')}
`; diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index a0c01a1..548cc66 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -1,13 +1,16 @@ import { AttributeType, EntityType } from '@/lib/Types'; import { dia, util } from '@joint/core'; import { EntityBody } from './EntityBody'; -import Attributes from '@/components/Attributes'; + +interface IEntityElement { + entity: EntityType; +} export class EntityElement extends dia.Element { initialize(...args: any[]) { super.initialize(...args); - const entity = this.get('data'); + const { entity } = this.get('data') as IEntityElement; if (entity) this.updateAttributes(entity); } @@ -25,6 +28,7 @@ export class EntityElement extends dia.Element { !attr.SchemaName.startsWith("msdyn") ) ].slice(0, maxVisibleItems); + // Map SchemaName to port name const portMap: Record = {}; for (const attr of visibleItems) { @@ -47,43 +51,74 @@ export class EntityElement extends dia.Element { const itemHeight = 28; const itemYSpacing = 8; - const itemXSpacing = 16; - const startY = 80 + itemYSpacing / 2; + const startY = 80 + itemYSpacing * 2; const height = startY + visibleItems.length * (itemHeight + itemYSpacing); - // Generate ports and SVG attributes - const portItems = visibleItems.map((attr, i) => { + const leftPorts: dia.Element.Port[] = []; + const rightPorts: dia.Element.Port[] = []; + + visibleItems.forEach((attr, i) => { const portId = `port-${attr.SchemaName.toLowerCase()}`; - const y = startY + i * (itemHeight + itemYSpacing); + const yPosition = startY + i * (itemHeight + itemYSpacing); - return { + const portConfig = { id: portId, - group: 'attribute', - args: { x: itemXSpacing, y }, + group: attr.AttributeType === "LookupAttribute" ? 'right' : 'left', + args: { y: yPosition }, attrs: { - 'attribute-rect': { - width: 480 - itemXSpacing * 2, - fill: "transparent", - height: itemHeight, - magnet: true - }, + circle: { + r: 6, + magnet: true, + stroke: '#31d0c6', + fill: '#fff', + strokeWidth: 2 + } } }; + + // Heuristic: If it's a LookupAttribute, treat as outgoing (right); otherwise, incoming (left) + if (attr.AttributeType === "LookupAttribute") { + portConfig.group = 'right'; + rightPorts.push(portConfig); + } else { + portConfig.group = 'left'; + leftPorts.push(portConfig); + } }); this.set('ports', { groups: { - attribute: { + left: { + position: { + name: 'left' + }, + attrs: { + circle: { + r: 6, + magnet: true, + stroke: '#31d0c6', + fill: '#fff', + strokeWidth: 2 + } + } + }, + right: { position: { - name: 'absolute' + name: 'right' }, - markup: [ - { tagName: 'rect', selector: 'attribute-rect' } - ] + attrs: { + circle: { + r: 6, + magnet: true, + stroke: '#31d0c6', + fill: '#fff', + strokeWidth: 2 + } + } } }, - items: portItems + items: [...leftPorts, ...rightPorts] }); this.set('attrs', { diff --git a/Website/package-lock.json b/Website/package-lock.json index 5d3db3b..20bd176 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -21,6 +21,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^5.9.6", + "lodash": "^4.17.21", "lucide-react": "^0.462.0", "next": "^15.2.2", "react": "^19.1.0", @@ -29,6 +30,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@types/lodash": "^4.17.19", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -2909,6 +2911,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-NYqRyg/hIQrYPT9lbOeYc3kIRabJDn/k4qQHIXUpx88CBDww2fD15Sg5kbXlW86zm2XEW4g0QxkTI3/Kfkc7xQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.17.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz", @@ -5619,6 +5628,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/Website/package.json b/Website/package.json index 0e3088d..9694436 100644 --- a/Website/package.json +++ b/Website/package.json @@ -22,6 +22,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^5.9.6", + "lodash": "^4.17.21", "lucide-react": "^0.462.0", "next": "^15.2.2", "react": "^19.1.0", @@ -30,6 +31,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@types/lodash": "^4.17.19", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/Website/routes/DiagramView.tsx b/Website/routes/DiagramView.tsx index 745dcea..5400ca5 100644 --- a/Website/routes/DiagramView.tsx +++ b/Website/routes/DiagramView.tsx @@ -1,29 +1,49 @@ 'use client'; -import React, { useEffect, useRef } from 'react' -import { dia, routers, shapes } from '@joint/core' +import React, { useEffect, useRef, useState } from 'react' +import { dia, shapes, util } from '@joint/core' import { Groups } from "../generated/Data" import { EntityElement } from '@/components/diagram/entity/entity'; -import { Link } from 'lucide-react'; -import { title } from 'process'; +import debounce from 'lodash/debounce'; interface IDiagramView {} +const routerType = "manhattan" +const routerPadding = 16; +const routerTries = 5000; +const routerDirections = 90; + export default function DiagramView({ }: IDiagramView) { const paperRef = useRef(null); + const graphInstance = useRef(new dia.Graph()); + + const [selectedKey, setSelectedKey] = useState(); const entityData = Groups[1].Entities; - console.log(entityData) + + useEffect(() => { + document.addEventListener('click', (e) => { + const target = (e.target as HTMLElement).closest('button[data-schema-name]') as HTMLElement; + if (!target) return; + + const schemaName = target.dataset.schemaName!; + const isKey = target.dataset.isKey === 'true'; + + if (isKey) { + setSelectedKey(schemaName); + } + }); + }, []) useEffect(() => { if (!paperRef.current) return; - const graph = new dia.Graph(); + if (!graphInstance?.current) return; const paper = new dia.Paper({ el: paperRef.current, - model: graph, + model: graphInstance?.current, width: 1920, height: 1080, - gridSize: 10, + gridSize: 8, background: { color: '#F8F9FA' }, }); @@ -35,93 +55,126 @@ export default function DiagramView({ }: IDiagramView) { const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); const entityElement = new EntityElement({ position: { x: 50, y: 50 }, - data: entity + data: { entity, setSelectedKey } }); - entityElement.addTo(graph); + entityElement.addTo(graphInstance.current); entityMap.set(entity.SchemaName, { element: entityElement, portMap }); } + + util.nextFrame(() => { + const allElements = graphInstance.current.getElements(); + const obstacles = allElements.map(el => el.getBBox().inflate(routerPadding)); - console.log(entityMap) + for (const entity of entityData) { + const entityInfo = entityMap.get(entity.SchemaName); + if (!entityInfo) continue; + const { portMap } = entityInfo; + const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); - // Create links for lookups - for (const entity of entityData) { - const entityInfo = entityMap.get(entity.SchemaName); - if (!entityInfo) continue; - const { portMap } = entityInfo; - const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); - // Start from index 1 (0 is key) - for (let i = 1; i < visibleItems.length; i++) { - const attr = visibleItems[i]; - if (attr.AttributeType !== "LookupAttribute") continue; - // For each target entity in the lookup - 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`]; - const sourceId = entityInfo.element.id; - const targetId = targetInfo.element.id; - - if (!sourcePort || !targetPort) { - console.warn("Missing port:", { sourcePort, targetPort, attr, target }); - continue; - } - if (!sourceId || !targetId) { - console.warn("Missing element id:", { sourceId, targetId, entityInfo, targetInfo }); - continue; - } + 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 link = new shapes.standard.Link({ - source: { id: entityInfo.element.id, port: sourcePort }, - target: { id: targetInfo.element.id, port: targetPort }, - router: { - name: 'rightAngle', - args: { - margin: 32, - sourceDirection: routers.rightAngle.Directions.RIGHT, - targetDirection: routers.rightAngle.Directions.LEFT - } - }, - connector: { name: 'rounded' }, - attrs: { - line: { - stroke: '#6366f1', - strokeWidth: 2, - targetMarker: { - 'type': 'path', - 'd': 'M 10 -5 L 0 0 L 10 5 Z', - 'fill': '#6366f1', - 'stroke': '#6366f1', + const sourcePort = portMap[attr.SchemaName.toLowerCase()]; + const targetPort = targetInfo.portMap[`${target.Name.toLowerCase()}id`]; + const sourceId = entityInfo.element.id; + const targetId = targetInfo.element.id; + + if (!sourcePort || !targetPort) continue; + + const link = new shapes.standard.Link({ + source: { id: sourceId, port: sourcePort }, + target: { id: targetId, port: targetPort }, + router: { + name: routerType, + args: { + startDirections: ['left', 'right'], + endDirections: ['left', 'right'], + step: paper.options.gridSize, + padding: routerPadding, + maximumLoops: routerTries, + isPointObstacle: (p: dia.Point) => { + return obstacles.some(obs => obs.bbox().containsPoint(p)) + }, + maxAllowedDirectionChange: routerDirections } - } - }, - labels: [ - { - position: 0.05, - attrs: { - text: { text: '*', fontSize: 18, fill: '#6366f1', fontWeight: 'bold' }, - rect: { fill: 'white', stroke: 'none' } + }, + connector: { name: 'rounded' }, + attrs: { + line: { + stroke: '#6366f1', + strokeWidth: 1, + targetMarker: { + type: 'path', + d: 'M 10 -5 L 0 0 L 10 5 Z', + fill: '#6366f1', + stroke: '#6366f1' + } } }, - { - position: 0.95, - attrs: { - text: { text: '+', fontSize: 18, fill: '#6366f1', fontWeight: 'bold' }, - rect: { fill: 'white', stroke: 'none' } + labels: [ + { + position: 0.05, + attrs: { + text: { text: '*', fontSize: 18, fill: '#6366f1', fontWeight: 'bold' }, + rect: { fill: 'white', stroke: 'none' } + } + }, + { + position: 0.95, + attrs: { + text: { text: '+', fontSize: 18, fill: '#6366f1', fontWeight: 'bold' }, + rect: { fill: 'white', stroke: 'none' } + } } - } - ] - }); - link.addTo(graph); + ] + }); + + link.addTo(graphInstance.current); + } } } - } + }); + + const reroute = debounce(() => { + const elements = graphInstance.current.getElements().filter(el => el.get('type') !== 'standard.Link'); + const obstacles = elements.map(el => { + const bbox = el.getBBox(); + const inflated = bbox.inflate(routerPadding); + return inflated; + }); + + graphInstance.current.getLinks().forEach(link => { + link.router(routerType, { + startDirections: ['left', 'right'], + endDirections: ['left', 'right'], + step: paper.options.gridSize, + padding: routerPadding, + maximumLoops: routerTries, + isPointObstacle: (p: dia.Point) => { + return obstacles.some(obs => obs.bbox().containsPoint(p)) + }, + maxAllowedDirectionChange: routerDirections + }); + }); + }, 100); + graphInstance.current.on('change:position change:size change:attrs.line', reroute); return () => { paper.remove(); }; }, [paperRef.current]); + useEffect(() =>{ + if (!selectedKey || !graphInstance.current) return; + console.log(graphInstance.current.getLinks()) + // const links = graphInstance.current.getLinks().filter(link => link.target().id === selectedKey); + }, [selectedKey]) + return ( <>
From ee1185cb992ea5ff4533a77888013dfeb75b43c0 Mon Sep 17 00:00:00 2001 From: boer Date: Fri, 27 Jun 2025 21:11:13 +0200 Subject: [PATCH 05/45] chore: playing around with attribute filtering --- Generator/DTO/Attributes/Attribute.cs | 4 ++-- Generator/DTO/Relationship.cs | 2 +- Generator/DataverseService.cs | 12 ++++++------ Generator/MetadataExtensions.cs | 7 +++++-- Website/components/diagram/entity/entity.ts | 2 +- Website/lib/Types.ts | 4 ++-- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Generator/DTO/Attributes/Attribute.cs b/Generator/DTO/Attributes/Attribute.cs index dd4db8c..51bcdcf 100644 --- a/Generator/DTO/Attributes/Attribute.cs +++ b/Generator/DTO/Attributes/Attribute.cs @@ -4,7 +4,7 @@ namespace Generator.DTO.Attributes; public abstract class Attribute { - public bool IsManaged { get; } + public bool IsCustom { get; } public string DisplayName { get; } public string SchemaName { get; } public string Description { get; } @@ -16,7 +16,7 @@ public abstract class Attribute protected Attribute(AttributeMetadata metadata) { - IsManaged = metadata.IsManaged ?? false; + IsCustom = metadata.IsCustomAttribute ?? false; DisplayName = metadata.DisplayName.UserLocalizedLabel?.Label ?? string.Empty; SchemaName = metadata.SchemaName; Description = metadata.Description.UserLocalizedLabel?.Label.PrettyDescription() ?? string.Empty; diff --git a/Generator/DTO/Relationship.cs b/Generator/DTO/Relationship.cs index 1ff6fc6..c26986a 100644 --- a/Generator/DTO/Relationship.cs +++ b/Generator/DTO/Relationship.cs @@ -4,7 +4,7 @@ namespace Generator.DTO; public record Relationship( - bool IsManaged, + bool IsCustom, string Name, string TableSchema, string LookupDisplayName, diff --git a/Generator/DataverseService.cs b/Generator/DataverseService.cs index 7ad9b44..126785e 100644 --- a/Generator/DataverseService.cs +++ b/Generator/DataverseService.cs @@ -86,7 +86,7 @@ public async Task> GetFilteredMetadata() { EntityMetadata = x, RelevantAttributes = - x.GetRelevantAttributes() + x.GetRelevantAttributes(attributesInSolution) .Where(x => x.DisplayName.UserLocalizedLabel?.Label != null) .ToList(), RelevantManyToMany = @@ -94,7 +94,6 @@ public async Task> GetFilteredMetadata() .Where(r => entityLogicalNamesInSolution.Contains(r.IntersectEntityName.ToLower())) .ToList(), }) - .Where(x => x.RelevantAttributes.Count > 0) .Where(x => x.EntityMetadata.DisplayName.UserLocalizedLabel?.Label != null) .ToList(); @@ -142,7 +141,7 @@ private static Record MakeRecord( var oneToMany = (entity.OneToManyRelationships ?? Enumerable.Empty()) .Where(x => logicalToSchema.ContainsKey(x.ReferencingEntity) && logicalToSchema[x.ReferencingEntity].IsInSolution && attributeLogicalToSchema[x.ReferencingEntity].ContainsKey(x.ReferencingAttribute)) .Select(x => new DTO.Relationship( - x.IsManaged ?? false, + x.IsCustomRelationship ?? false, x.ReferencingEntityNavigationPropertyName, logicalToSchema[x.ReferencingEntity].Name, attributeLogicalToSchema[x.ReferencingEntity][x.ReferencingAttribute], @@ -154,7 +153,7 @@ private static Record MakeRecord( var manyToMany = relevantManyToMany .Where(x => logicalToSchema.ContainsKey(x.Entity1LogicalName) && logicalToSchema[x.Entity1LogicalName].IsInSolution) .Select(x => new DTO.Relationship( - x.IsManaged ?? false, + x.IsCustomRelationship ?? false, x.Entity1AssociatedMenuConfiguration.Behavior == AssociatedMenuBehavior.UseLabel ? x.Entity1AssociatedMenuConfiguration.Label.UserLocalizedLabel?.Label ?? x.Entity1NavigationPropertyName : x.Entity1NavigationPropertyName, @@ -272,7 +271,6 @@ await Parallel.ForEachAsync( async (objectId, token) => { metadata.Add(await client.RetrieveEntityAsync(objectId, token)); - }); return metadata; @@ -295,7 +293,6 @@ await Parallel.ForEachAsync( async (logicalName, token) => { metadata.Add(await client.RetrieveEntityByLogicalNameAsync(logicalName, token)); - }); return metadata; @@ -519,6 +516,9 @@ private TokenCredential GetTokenCredential(ILogger logger) if (configuration["DataverseClientId"] != null && configuration["DataverseClientSecret"] != null) return new ClientSecretCredential(configuration["TenantId"], configuration["DataverseClientId"], configuration["DataverseClientSecret"]); + if (configuration["DataverseClientId"] != null) + return new InteractiveBrowserCredential(); + logger.LogTrace("Using Default Managed Identity"); return new DefaultAzureCredential(); // in azure this will be managed identity, locally this depends... se midway of this post for the how local identity is chosen: https://dreamingincrm.com/2021/11/16/connecting-to-dataverse-from-function-app-using-managed-identity/ diff --git a/Generator/MetadataExtensions.cs b/Generator/MetadataExtensions.cs index 4406dfa..480ea18 100644 --- a/Generator/MetadataExtensions.cs +++ b/Generator/MetadataExtensions.cs @@ -4,14 +4,17 @@ namespace Generator; public static class MetadataExtensions { - public static IEnumerable GetRelevantAttributes(this EntityMetadata entity) => entity.Attributes.Where(IsCustomOrModifiedStandardField); + public static IEnumerable GetRelevantAttributes(this EntityMetadata entity, HashSet attributesInSolution) => + entity.Attributes + //.Where(attr => attr.MetadataId.HasValue && attributesInSolution.TryGetValue(attr.MetadataId.Value, out _)) // solutioncomponents for attributes (attributesInSolution) dont seem to return all relevant attributes? Maybe something to do with solution layering or idk tbh + .Where(IsCustomOrModifiedStandardField); private static readonly Func IsCustomOrModifiedStandardField = (AttributeMetadata attribute) => { bool isCustomField = attribute.IsCustomAttribute.HasValue && attribute.IsCustomAttribute.Value; bool hasBeenModified = attribute.ModifiedOn.HasValue && attribute.CreatedOn.HasValue && attribute.ModifiedOn != attribute.CreatedOn; - return isCustomField || hasBeenModified; + return (isCustomField || hasBeenModified); }; internal static string PrettyDescription(this string description) => diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index 548cc66..21b9bc7 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -24,7 +24,7 @@ export class EntityElement extends dia.Element { { DisplayName: "Key", SchemaName: entity.SchemaName + "id" } as AttributeType, ...entity.Attributes.filter(attr => attr.AttributeType === "LookupAttribute" && - !attr.IsManaged && + !attr.IsCustom && !attr.SchemaName.startsWith("msdyn") ) ].slice(0, maxVisibleItems); diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts index 0a21c27..7b6c35f 100644 --- a/Website/lib/Types.ts +++ b/Website/lib/Types.ts @@ -41,7 +41,7 @@ export const enum CalculationMethods { } export type BaseAttribute = { - IsManaged: boolean, + IsCustom: boolean, DisplayName: string, SchemaName: string, Description: string | null, @@ -160,7 +160,7 @@ export type CascadeConfigurationType = { } export type RelationshipType = { - IsManaged: boolean, + IsCustom: boolean, Name: string, TableSchema: string, LookupDisplayName: string, From 6da34140c84b808a3a27f108b47e0ba63710e3e1 Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 28 Jun 2025 23:26:09 +0200 Subject: [PATCH 06/45] chore: AI rules and diagram refactor using them --- Website/.cursor/ai-rules.md | 1 + .../ai-rules/DIAGRAM_REFACTORING_PROMPT.md | 228 ++++++++++ Website/ai-rules/README.md | 416 ++++++++++++++++++ Website/app/globals.css | 6 + Website/components/diagram/DiagramCanvas.tsx | 47 ++ .../components/diagram/DiagramControls.tsx | 116 +++++ .../components/diagram/DiagramResetButton.tsx | 26 ++ .../components/diagram/EntityInfoPanel.tsx | 95 ++++ .../components/diagram/GridLayoutManager.ts | 115 +++++ Website/components/diagram/GroupSelector.tsx | 74 ++++ .../diagram/ZoomCoordinateIndicator.tsx | 39 ++ Website/components/diagram/entity/entity.ts | 3 +- Website/components/ui/card.tsx | 79 ++++ Website/components/ui/scroll-area.tsx | 48 ++ Website/contexts/DiagramContext.tsx | 28 ++ Website/hooks/useDiagram.ts | 299 +++++++++++++ Website/package-lock.json | 163 +++++++ Website/package.json | 1 + Website/routes/DiagramView.tsx | 212 +++++++-- 19 files changed, 1956 insertions(+), 40 deletions(-) create mode 100644 Website/.cursor/ai-rules.md create mode 100644 Website/ai-rules/DIAGRAM_REFACTORING_PROMPT.md create mode 100644 Website/ai-rules/README.md create mode 100644 Website/components/diagram/DiagramCanvas.tsx create mode 100644 Website/components/diagram/DiagramControls.tsx create mode 100644 Website/components/diagram/DiagramResetButton.tsx create mode 100644 Website/components/diagram/EntityInfoPanel.tsx create mode 100644 Website/components/diagram/GridLayoutManager.ts create mode 100644 Website/components/diagram/GroupSelector.tsx create mode 100644 Website/components/diagram/ZoomCoordinateIndicator.tsx create mode 100644 Website/components/ui/card.tsx create mode 100644 Website/components/ui/scroll-area.tsx create mode 100644 Website/contexts/DiagramContext.tsx create mode 100644 Website/hooks/useDiagram.ts diff --git a/Website/.cursor/ai-rules.md b/Website/.cursor/ai-rules.md new file mode 100644 index 0000000..1aa67a7 --- /dev/null +++ b/Website/.cursor/ai-rules.md @@ -0,0 +1 @@ +Read the file at ai-rules/READNE.md. When answering my questions, you MUST always follow those rules. \ No newline at end of file diff --git a/Website/ai-rules/DIAGRAM_REFACTORING_PROMPT.md b/Website/ai-rules/DIAGRAM_REFACTORING_PROMPT.md new file mode 100644 index 0000000..25834cd --- /dev/null +++ b/Website/ai-rules/DIAGRAM_REFACTORING_PROMPT.md @@ -0,0 +1,228 @@ +# Diagram Page Refactoring Prompt + +## Overview +Refactor the existing `routes/DiagramView.tsx` to implement a new diagram interface with enhanced functionality including a sidepane for group selection and diagram controls, and a redesigned diagram canvas with improved layout and styling. + +## Current State Analysis +The current implementation uses: +- JointJS for diagram rendering +- React Context for state management (`DiagramContext`) +- Custom hooks (`useDiagram`) for diagram operations +- shadcn/ui components for the interface +- Entity data from `generated/Data.ts` with multiple groups + +## Required Features + +### 1. Sidepane Implementation + +#### 1.a Group Selection +- **Location**: Left sidepane +- **Functionality**: + - Display all available groups from `Groups` array + - Allow user to select a group to display its entities + - Show group name and entity count + - Highlight currently selected group + - Default to first group if none selected + +#### 1.b Diagram Reset Control +- **Location**: Sidepane controls section +- **Functionality**: + - Reset button to return diagram to default view + - Reset zoom to 100% + - Reset pan position to center + - Clear any selections + +#### 1.c Entity Information Display +- **Location**: Sidepane information section +- **Functionality**: + - Show high-level statistics for current group + - Display total entity count + - Show relationship count + - Display group description if available + - Show entity list with basic info (name, type, description) + +### 2. Diagram Canvas Redesign + +#### 2.a Light Yellow Background with Black Dots +- **Background Color**: Light yellow (`#fef3c7` or similar) +- **Dot Pattern**: Small black dots in a grid pattern +- **Implementation**: Use CSS background pattern or JointJS background configuration + +#### 2.b Zoom and Pan Functionality +- **Zoom**: + - Mouse wheel zoom (already implemented) + - Zoom buttons in sidepane + - Zoom range: 0.1x to 3x + - Zoom to mouse position +- **Pan**: + - Click and drag on empty canvas area + - Smooth panning with visual feedback + - Pan limits to prevent elements from going off-screen + +#### 2.c Zoom and Coordinate Indicator +- **Location**: Bottom-right corner overlay +- **Display**: + - Current zoom level (e.g., "100%", "150%") + - Current mouse coordinates relative to diagram + - Format: "Zoom: 100% | X: 150, Y: 200" +- **Styling**: Semi-transparent background with readable text + +#### 2.d Grid Layout with Padding +- **Layout Algorithm**: + - Arrange entities in a grid pattern + - Calculate optimal grid size based on entity count + - Maintain consistent spacing between entities + - Center the grid on the canvas +- **Padding**: + - Minimum 50px padding between entities + - 100px padding from canvas edges + - Responsive to zoom level + +### 3. Preserve Existing Functionality + +#### 3.a Current Styling +- **Entity Elements**: Keep existing `EntityElement` styling +- **Links**: Maintain current link styling and routing +- **Colors**: Preserve existing color scheme for entities and relationships +- **Typography**: Keep existing font styles and sizes + +#### 3.b Links and Relationships +- **Routing**: Maintain Manhattan routing algorithm +- **Styling**: Keep current link colors (`#6366f1`) +- **Labels**: Preserve relationship labels (`*` and `+`) +- **Arrow Markers**: Keep existing arrow styling +- **Debouncing**: Maintain existing reroute debouncing + +## Technical Implementation Requirements + +### State Management +```typescript +interface DiagramState { + selectedGroup: GroupType | null; + currentEntities: EntityType[]; + zoom: number; + panPosition: { x: number; y: number }; + mousePosition: { x: number; y: number } | null; +} +``` + +### New Components to Create +1. **GroupSelector**: Component for group selection in sidepane +2. **EntityInfoPanel**: Component for displaying entity information +3. **DiagramResetButton**: Component for reset functionality +4. **ZoomCoordinateIndicator**: Component for zoom/coordinate display +5. **GridLayoutManager**: Utility for calculating grid positions + +### File Structure Changes +``` +components/ +├── diagram/ +│ ├── GroupSelector.tsx (new) +│ ├── EntityInfoPanel.tsx (new) +│ ├── DiagramResetButton.tsx (new) +│ ├── ZoomCoordinateIndicator.tsx (new) +│ └── GridLayoutManager.ts (new) +``` + +### Context Updates +Extend `DiagramContext` to include: +- Group selection state +- Grid layout calculations +- Mouse position tracking +- Reset functionality + +## Implementation Guidelines + +### 1. Follow Existing Patterns +- Use the established `DiagramContext` pattern +- Follow the custom hook structure from `useDiagram` +- Maintain TypeScript strict typing +- Use shadcn/ui components consistently + +### 2. Performance Considerations +- Use refs for values that don't need re-renders +- Implement proper cleanup for event listeners +- Use debouncing for expensive operations +- Optimize grid layout calculations + +### 3. Responsive Design +- Ensure sidepane is collapsible +- Make diagram canvas responsive to window size +- Handle different screen sizes appropriately +- Maintain usability on smaller screens + +### 4. Accessibility +- Add proper ARIA labels +- Ensure keyboard navigation works +- Provide screen reader support +- Maintain focus management + +## Code Quality Requirements + +### 1. TypeScript +- Strict typing for all new components +- Proper interface definitions +- Type guards where necessary +- No `any` types + +### 2. Error Handling +- Graceful handling of missing data +- Proper error boundaries +- User-friendly error messages +- Fallback states + +### 3. Testing Considerations +- Components should be testable +- Mock JointJS for testing +- Test state changes +- Test user interactions + +## Success Criteria + +### Functional Requirements +- [ ] Group selection works correctly +- [ ] Diagram reset functionality works +- [ ] Entity information displays properly +- [ ] Grid layout arranges entities correctly +- [ ] Zoom and pan work smoothly +- [ ] Coordinate indicator updates in real-time +- [ ] Background pattern displays correctly + +### Performance Requirements +- [ ] No performance degradation from current implementation +- [ ] Smooth zoom and pan operations +- [ ] Responsive UI interactions +- [ ] Efficient grid layout calculations + +### User Experience Requirements +- [ ] Intuitive group selection +- [ ] Clear visual feedback for interactions +- [ ] Consistent styling with existing components +- [ ] Smooth transitions and animations + +## Migration Strategy + +### Phase 1: Sidepane Implementation +1. Create new sidepane components +2. Implement group selection logic +3. Add entity information display +4. Integrate with existing context + +### Phase 2: Canvas Redesign +1. Update background styling +2. Implement grid layout algorithm +3. Add zoom/coordinate indicator +4. Enhance zoom and pan functionality + +### Phase 3: Integration and Testing +1. Integrate all components +2. Test all functionality +3. Optimize performance +4. Fix any issues + +## Notes +- Preserve all existing functionality while adding new features +- Maintain backward compatibility with existing data structure +- Follow the established code patterns and conventions +- Ensure the refactored code is maintainable and extensible +- Document any significant changes or new patterns introduced \ No newline at end of file diff --git a/Website/ai-rules/README.md b/Website/ai-rules/README.md new file mode 100644 index 0000000..7e87b82 --- /dev/null +++ b/Website/ai-rules/README.md @@ -0,0 +1,416 @@ +# 🧠 AI Agent Rules for DataModelViewer Codebase + +You are an expert in **TypeScript**, **Node.js**, **Next.js App Router**, **React**, **Radix**, **JointJS**, **TailwindCSS**, and general frontend development. + +--- + +## 📘 Project Overview + +This is a **DataModelViewer** built with Next.js 15, React 19, TypeScript, and JointJS for data model visualization. It uses a modern tech stack including **shadcn/ui**, **Tailwind CSS**, and follows React best practices. + +--- + +## 🛠 Core Technologies & Dependencies + +- **Framework**: Next.js 15 (App Router) +- **UI Library**: React 19 +- **Language**: TypeScript (strict mode) +- **Styling**: Tailwind CSS + shadcn/ui +- **Diagram Library**: JointJS (@joint/core v4.1.3) +- **Icons**: Lucide React +- **Utilities**: Lodash, clsx, tailwind-merge +- **Authentication**: jose (JWT) + +--- + +## 🏗 Architecture Patterns + +### 📂 1. File Structure +``` +Website/ +├── app/ # Next.js App Router +├── components/ # Reusable components +│ ├── ui/ # shadcn/ui components +│ ├── diagram/ # Diagram-specific +│ └── entity/ # Entity components +├── contexts/ # React Context +├── hooks/ # Custom hooks +├── lib/ # Utilities/constants +├── routes/ # Route components +└── public/ # Static assets +``` + +--- + +### 🧩 2. Component Architecture +- Use **functional components** and hooks. +- Always **strictly type** props and state. +- Define **props interfaces**. +- Use destructuring with defaults. + +--- + +### ⚙️ 3. State Management +- **React Context** for global state (e.g., `DiagramContext`). +- **Custom Hooks** for complex logic (`useDiagram`, `useEntitySelection`). +- **useState** for local state. +- **useRef** for mutable values not requiring re-renders. + +--- + +## 🧑‍💻 Coding Standards + +### 1️⃣ TypeScript Conventions +```typescript +interface ComponentProps { + data: EntityData; + onSelect?: (id: string) => void; + className?: string; +} + +const handleClick = (e: React.MouseEvent) => { + // handler logic +}; + +const useCustomHook = (): CustomHookReturn => { + // hook logic +}; +``` + +--- + +### 2️⃣ Component Patterns +```typescript +interface IComponentName { + data: number[]; + onSelect: () => void; + className: string; +} + +function ComponentName({ data, onSelect, className }: IComponentName) { + const [state, setState] = useState(); + + useEffect(() => { + // effect + }, [dependencies]); + + const handleAction = useCallback(() => { + // action + }, [dependencies]); + + return ( +
+ {/* content */} +
+ ); +} +``` + +--- + +### 3️⃣ Styling Conventions +- Use **Tailwind utility classes**. +- For variants, use **class-variance-authority**. +- Ensure **responsive design**. + +--- + +## 🟢 Diagram-Specific Rules + +### 1️⃣ JointJS Integration + +1. **Model and View Separation** + - Keep model logic separate from view rendering. + - Define `joint.dia.Element` and `joint.dia.Link` for business data. + - Use `joint.shapes` for reusable shapes. + - Use `ElementView` for rendering/UI. + +2. **Graph as Single Source of Truth** + - Always store state in `joint.dia.Graph`. + - Never rely on paper alone. + - Use `element.set()` to trigger events. + +3. **Batch Operations** + ```typescript + graph.startBatch('batch-updates'); + graph.addCells([cell1, cell2, link]); + cell1.resize(100, 80); + graph.stopBatch('batch-updates'); + ``` + +4. **Unique IDs** + - Ensure each element/link has a unique `id`. + +5. **Paper Events** + - Use events (`cell:pointerdown`, `element:pointerclick`) for interactions. + - Avoid mixing DOM events. + +6. **Memory Cleanup** + - Call `paper.remove()` and `graph.clear()` when destroying. + - Remove listeners. + +7. **Debounce/Throttle** + - Throttle high-frequency handlers (`cell:position`). + +8. **Styling** + - Use Tailwind classes over hardcoded styles. + +--- + +### 2️⃣ Entity Element Patterns +- **Custom Elements**: Extend JointJS. +- **Ports**: Consistent naming. +- **Data Binding**: Link model data to visuals. +- **Events**: Proper interaction handling. + +--- + +## 📝 File Naming + +- **Components**: PascalCase (`DiagramCanvas.tsx`) +- **Hooks**: camelCase with `use` (`useDiagram.ts`) +- **Utilities**: camelCase (`utils.ts`) +- **Shared Types**: `types.ts` + +--- + +## 🧷 Import & Export + +### Import Organization +```typescript +// React & Next.js +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +// Third-party +import { dia } from '@joint/core'; +import { Button } from '@/components/ui/button'; + +// Local +import { useDiagramContext } from '@/contexts/DiagramContext'; +import { cn } from '@/lib/utils'; + +// Relative +import { EntityElement } from './entity/entity'; +``` + +--- + +### Export Patterns +```typescript +// Named component +export const ComponentName: React.FC = () => {}; + +// Default page +export default function PageName() {} + +// Utilities +export function utilityFunction() {} +export const CONSTANT_VALUE = 'value'; +``` + +--- + +## 🚀 Performance Guidelines + +### React +- `useCallback` for handlers. +- `useMemo` for expensive computations. +- `React.memo` for frequently re-rendered components. +- Always include dependencies in hooks. + +--- + +### Diagram +- **Debounce** expensive handlers. +- **Clean up** listeners. +- **Refs over state** for mutable values. +- **Batch** updates. + +--- + +### Bundle +- Use **dynamic imports** for large components. +- Ensure **tree shaking**. +- Leverage **Next.js code splitting**. + +--- + +## ⚠️ Error Handling + +### TypeScript +- Strict mode. +- Type guards. +- Optional chaining. +- Null checks. + +--- + +### Runtime +- Error boundaries. +- Try/catch for async. +- Validate all props and data. + +--- + +## 🧪 Testing + +### Components +- Unit tests. +- Integration tests. +- Accessibility tests. + +--- + +### Diagram +- Mock JointJS. +- Test interactions and state. + +--- + +## 🔒 Security + +### Authentication +- Use **jose** for JWT. +- Protect routes. +- Validate inputs. + +--- + +### Data +- Sanitize before rendering. +- Escape output (prevent XSS). +- Use CSRF protection if needed. + +--- + +## 🗂 Documentation Standards + +- Use **JSDoc** for complex logic. +- Add **inline comments** where necessary. +- Include **TODOs** for incomplete work. +- Document setup, usage, and architecture in README. + +--- + +## 🏷 Common Patterns + +### Custom Hook +```typescript +export const useCustomHook = (): HookReturn => { + const [state, setState] = useState(initialState); + + const action = useCallback(() => { + // logic + }, []); + + useEffect(() => { + // setup + return () => { + // cleanup + }; + }, []); + + return { state, action }; +}; +``` + +--- + +### Context Provider +```typescript +const Context = createContext(null); + +export const Provider: React.FC = ({ children }) => { + const value = useCustomHook(); + return {children}; +}; + +export const useContext = (): ContextType => { + const context = useContext(Context); + if (!context) throw new Error('Must be used within Provider'); + return context; +}; +``` + +--- + +### Component Composition +```typescript +const ParentComponent = () => ( + + + +); + +const ChildComponent = () => { + const context = useContext(); + return
{/* Use context */}
; +}; +``` + +--- + +## 🚫 Anti-Patterns to Avoid + +### React +- Avoid `useState` for refs—use `useRef`. +- Don’t create objects in render—use `useMemo`. +- Always clean up effects. +- Never mutate state directly. + +--- + +### Diagram +- Don’t reinitialize `paper` unnecessarily—use refs. +- Always clean up event listeners. +- Avoid storing zoom/pan in React state. +- Debounce expensive ops. + +--- + +### TypeScript +- Avoid `any`. +- Fix all errors—don’t ignore them. +- Use type guards over assertions. +- Always check for `null`. + +--- + +## 🧭 Migration Guidelines + +### Updating Dependencies +- Review changelogs for breaking changes. +- Update gradually. +- Test thoroughly. +- Verify TypeScript compatibility. + +--- + +### Refactoring +- Preserve functionality. +- Update tests. +- Document changes. +- Validate performance. + +--- + +## 🆘 Emergency Procedures + +### When Things Break +- Check console errors. +- Inspect network requests. +- Verify React state/context. +- Roll back via Git if needed. + +--- + +### Performance Issues +- Profile with React DevTools. +- Check for excessive re-renders. +- Analyze bundle size. +- Apply optimizations. + +--- + +**Remember:** Prioritize clarity and maintainability. When in doubt, follow established patterns and documented practices. diff --git a/Website/app/globals.css b/Website/app/globals.css index 627a47d..6a39d63 100644 --- a/Website/app/globals.css +++ b/Website/app/globals.css @@ -91,4 +91,10 @@ body { .striped tr:nth-child(even) { @apply bg-gray-100 } + + .dotted-background { + background-color: #fef3c7; + background-image: radial-gradient(circle, #000 1px, transparent 1px); + background-size: 20px 20px; + } } \ No newline at end of file diff --git a/Website/components/diagram/DiagramCanvas.tsx b/Website/components/diagram/DiagramCanvas.tsx new file mode 100644 index 0000000..6b38049 --- /dev/null +++ b/Website/components/diagram/DiagramCanvas.tsx @@ -0,0 +1,47 @@ +import React, { useRef, useEffect } from 'react'; +import { useDiagramContext } from '@/contexts/DiagramContext'; + +interface DiagramCanvasProps { + children?: React.ReactNode; +} + +export const DiagramCanvas: React.FC = ({ children }) => { + const canvasRef = useRef(null); + const { + isPanning, + initializePaper, + destroyPaper + } = useDiagramContext(); + + useEffect(() => { + if (canvasRef.current) { + const paper = initializePaper(canvasRef.current, { + background: { + color: '#fef3c7' // Light yellow background + } + }); + + return () => { + destroyPaper(); + }; + } + }, [initializePaper, destroyPaper]); + + return ( +
+
+ + {/* Panning indicator */} + {isPanning && ( +
+ Panning... +
+ )} + + {children} +
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagram/DiagramControls.tsx b/Website/components/diagram/DiagramControls.tsx new file mode 100644 index 0000000..30a9ce9 --- /dev/null +++ b/Website/components/diagram/DiagramControls.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { + ZoomIn, + ZoomOut, + RotateCcw, + Maximize, + Settings, + Layers, + Search +} from 'lucide-react'; +import { useDiagramContext } from '@/contexts/DiagramContext'; + +export const DiagramControls: React.FC = () => { + const { + zoom, + resetView, + fitToScreen + } = useDiagramContext(); + + return ( +
+
+

View Controls

+
+ + +
+
+ + + +
+

Tools

+
+ + + +
+
+
+ ); +}; + +export const DiagramZoomDisplay: React.FC = () => { + const { zoom } = useDiagramContext(); + + return ( +
+ Zoom: {Math.round(zoom * 100)}% +
+ ); +}; + +export const DiagramZoomControls: React.FC = () => { + const { zoomIn, zoomOut } = useDiagramContext(); + + return ( +
+ + +
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagram/DiagramResetButton.tsx b/Website/components/diagram/DiagramResetButton.tsx new file mode 100644 index 0000000..477e68f --- /dev/null +++ b/Website/components/diagram/DiagramResetButton.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { RotateCcw } from 'lucide-react'; + +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/diagram/EntityInfoPanel.tsx b/Website/components/diagram/EntityInfoPanel.tsx new file mode 100644 index 0000000..11ec056 --- /dev/null +++ b/Website/components/diagram/EntityInfoPanel.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { GroupType, EntityType } from '@/lib/Types'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Database, Link, FileText } from 'lucide-react'; + +interface EntityInfoPanelProps { + selectedGroup: GroupType | null; +} + +export const EntityInfoPanel: React.FC = ({ + selectedGroup +}) => { + if (!selectedGroup) { + return ( +
+ +

Select a group to view information

+
+ ); + } + + const entityCount = selectedGroup.Entities.length; + const relationshipCount = selectedGroup.Entities.reduce( + (total, entity) => total + entity.Relationships.length, + 0 + ); + + return ( +
+
+

Group Information

+ +
+ + + + + + {selectedGroup.Name} + + + {/* Statistics */} +
+
+
+ {entityCount} +
+
+ {entityCount === 1 ? 'Entity' : 'Entities'} +
+
+
+
+ {relationshipCount} +
+
+ {relationshipCount === 1 ? 'Relationship' : 'Relationships'} +
+
+
+ + + + {/* Entity List */} +
+
+ + Entities +
+ +
+ {selectedGroup.Entities.map((entity) => ( +
+
{entity.DisplayName}
+
+ {entity.Attributes.length} attributes +
+ {entity.Description && ( +
+ {entity.Description} +
+ )} +
+ ))} +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts new file mode 100644 index 0000000..e1e3a57 --- /dev/null +++ b/Website/components/diagram/GridLayoutManager.ts @@ -0,0 +1,115 @@ +import { EntityType } from '@/lib/Types'; + +export interface GridLayoutOptions { + containerWidth: number; + containerHeight: number; + entityWidth: number; + entityHeight: number; + padding: number; + margin: number; +} + +export interface GridPosition { + x: number; + y: number; +} + +export interface GridLayoutResult { + positions: GridPosition[]; + gridWidth: number; + gridHeight: number; + columns: number; + rows: number; +} + +/** + * Calculates optimal grid layout for entities + */ +export const calculateGridLayout = ( + entities: EntityType[], + options: GridLayoutOptions +): GridLayoutResult => { + const { containerWidth, containerHeight, entityWidth, entityHeight, padding, margin } = options; + + if (entities.length === 0) { + return { + positions: [], + gridWidth: 0, + gridHeight: 0, + columns: 0, + rows: 0 + }; + } + + // Calculate available space + const availableWidth = containerWidth - (margin * 2); + const availableHeight = containerHeight - (margin * 2); + + // Calculate how many entities can fit in a row + const entitiesPerRow = Math.floor(availableWidth / (entityWidth + padding)); + const columns = Math.max(1, entitiesPerRow); + const rows = Math.ceil(entities.length / columns); + + // Calculate grid dimensions + const gridWidth = columns * entityWidth + (columns - 1) * padding; + const gridHeight = rows * entityHeight + (rows - 1) * padding; + + // Calculate starting position to center the grid + const startX = margin + (availableWidth - gridWidth) / 2; + const startY = margin + (availableHeight - gridHeight) / 2; + + // Calculate positions for each entity + const positions: GridPosition[] = []; + + for (let i = 0; i < entities.length; i++) { + const row = Math.floor(i / columns); + const col = i % columns; + + const x = startX + col * (entityWidth + padding); + const y = startY + row * (entityHeight + padding); + + positions.push({ x, y }); + } + + return { + positions, + gridWidth, + gridHeight, + columns, + rows + }; +}; + +/** + * Estimates entity dimensions based on content + */ +export const estimateEntityDimensions = (entity: EntityType): { width: number; height: number } => { + // Base dimensions + const baseWidth = 200; + const baseHeight = 120; + + // Adjust based on number of attributes + const attributeCount = entity.Attributes.length; + const heightAdjustment = Math.min(attributeCount * 8, 80); // Max 80px additional height + + // Adjust based on name length + const nameLength = entity.DisplayName.length; + const widthAdjustment = Math.min(nameLength * 2, 60); // Max 60px additional width + + return { + width: baseWidth + widthAdjustment, + height: baseHeight + heightAdjustment + }; +}; + +/** + * Gets default layout options + */ +export const getDefaultLayoutOptions = (): GridLayoutOptions => ({ + containerWidth: 1200, + containerHeight: 800, + entityWidth: 200, + entityHeight: 120, + padding: 50, + margin: 100 +}); \ No newline at end of file diff --git a/Website/components/diagram/GroupSelector.tsx b/Website/components/diagram/GroupSelector.tsx new file mode 100644 index 0000000..efcacdd --- /dev/null +++ b/Website/components/diagram/GroupSelector.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { GroupType } from '@/lib/Types'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { FolderOpen, Folder } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface GroupSelectorProps { + groups: GroupType[]; + selectedGroup: GroupType | null; + onGroupSelect: (group: GroupType) => void; +} + +export const GroupSelector: React.FC = ({ + groups, + selectedGroup, + onGroupSelect +}) => { + return ( +
+
+

Groups

+ + {groups.length} total + +
+ + + + +
+ {groups.map((group) => { + const isSelected = selectedGroup?.Name === group.Name; + const entityCount = group.Entities.length; + + return ( + + ); + })} +
+
+
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagram/ZoomCoordinateIndicator.tsx b/Website/components/diagram/ZoomCoordinateIndicator.tsx new file mode 100644 index 0000000..6ed9891 --- /dev/null +++ b/Website/components/diagram/ZoomCoordinateIndicator.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { ZoomIn, MousePointer } from 'lucide-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/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index 21b9bc7..24780b4 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -24,8 +24,7 @@ export class EntityElement extends dia.Element { { DisplayName: "Key", SchemaName: entity.SchemaName + "id" } as AttributeType, ...entity.Attributes.filter(attr => attr.AttributeType === "LookupAttribute" && - !attr.IsCustom && - !attr.SchemaName.startsWith("msdyn") + attr.IsCustomAttribute ) ].slice(0, maxVisibleItems); diff --git a/Website/components/ui/card.tsx b/Website/components/ui/card.tsx new file mode 100644 index 0000000..e855d73 --- /dev/null +++ b/Website/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } \ No newline at end of file diff --git a/Website/components/ui/scroll-area.tsx b/Website/components/ui/scroll-area.tsx new file mode 100644 index 0000000..c9e741f --- /dev/null +++ b/Website/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } \ No newline at end of file diff --git a/Website/contexts/DiagramContext.tsx b/Website/contexts/DiagramContext.tsx new file mode 100644 index 0000000..8f5eb50 --- /dev/null +++ b/Website/contexts/DiagramContext.tsx @@ -0,0 +1,28 @@ +import React, { createContext, useContext, ReactNode } from 'react'; +import { useDiagram, DiagramState, DiagramActions } from '@/hooks/useDiagram'; + +interface DiagramContextType extends DiagramState, DiagramActions {} + +const DiagramContext = createContext(null); + +interface DiagramProviderProps { + children: ReactNode; +} + +export const DiagramProvider: React.FC = ({ children }) => { + const diagramState = useDiagram(); + + return ( + + {children} + + ); +}; + +export const useDiagramContext = (): DiagramContextType => { + const context = useContext(DiagramContext); + if (!context) { + throw new Error('useDiagramContext must be used within a DiagramProvider'); + } + return context; +}; \ No newline at end of file diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts new file mode 100644 index 0000000..3dd3003 --- /dev/null +++ b/Website/hooks/useDiagram.ts @@ -0,0 +1,299 @@ +import { useRef, useState, useCallback, useEffect } from 'react'; +import { dia } from '@joint/core'; +import { GroupType, EntityType } from '@/lib/Types'; + +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 }; +} + +export interface DiagramActions { + zoomIn: () => void; + zoomOut: () => void; + resetView: () => void; + fitToScreen: () => void; + setZoom: (zoom: number) => void; + setIsPanning: (isPanning: boolean) => void; + selectElement: (elementId: string) => void; + clearSelection: () => void; + 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; +} + +export const useDiagram = (): DiagramState & DiagramActions => { + const paperRef = useRef(null); + const graphRef = useRef(null); + const zoomRef = useRef(1); + const isPanningRef = useRef(false); + const cleanupRef = useRef<(() => void) | null>(null); + + 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 }); + + // 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) => { + setSelectedElements(prev => + prev.includes(elementId) ? prev : [...prev, elementId] + ); + }, []); + + const clearSelection = useCallback(() => { + setSelectedElements([]); + }, []); + + const selectGroup = useCallback((group: GroupType) => { + setSelectedGroup(group); + setCurrentEntities(group.Entities); + clearSelection(); + }, [clearSelection]); + + const updateMousePosition = useCallback((position: { x: number; y: number } | null) => { + setMousePosition(position); + }, []); + + const updatePanPosition = useCallback((position: { x: number; y: number }) => { + setPanPosition(position); + }, []); + + const initializePaper = useCallback((container: HTMLElement, options: any = {}) => { + // Create graph if it doesn't exist + if (!graphRef.current) { + graphRef.current = new dia.Graph(); + } + + // Create paper with dotted background + const paper = new dia.Paper({ + el: container, + model: graphRef.current, + width: '100%', + height: '100%', + gridSize: 8, + background: { + color: '#fef3c7', // Light yellow background + ...options.background + }, + ...options + }); + + paperRef.current = paper; + + // Setup event listeners + paper.on('blank:pointerdown', () => { + updatePanningDisplay(true); + const paperEl = paper.el as HTMLElement; + paperEl.style.cursor = 'grabbing'; + }); + + paper.on('blank:pointerup', () => { + updatePanningDisplay(false); + const paperEl = paper.el as HTMLElement; + paperEl.style.cursor = 'default'; + }); + + paper.on('blank:pointermove', (evt: any) => { + if (isPanningRef.current) { + 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 }); + } + }); + + // 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]); + + const destroyPaper = useCallback(() => { + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + paperRef.current = null; + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + destroyPaper(); + }; + }, [destroyPaper]); + + return { + // State + zoom, + isPanning, + selectedElements, + paper: paperRef.current, + graph: graphRef.current, + selectedGroup, + currentEntities, + mousePosition, + panPosition, + + // Actions + zoomIn, + zoomOut, + resetView, + fitToScreen, + setZoom, + setIsPanning, + selectElement, + clearSelection, + initializePaper, + destroyPaper, + selectGroup, + updateMousePosition, + updatePanPosition, + }; +}; \ No newline at end of file diff --git a/Website/package-lock.json b/Website/package-lock.json index 20bd176..7171089 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -13,6 +13,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", @@ -1843,6 +1844,168 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", + "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", diff --git a/Website/package.json b/Website/package.json index 9694436..cb0c945 100644 --- a/Website/package.json +++ b/Website/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", diff --git a/Website/routes/DiagramView.tsx b/Website/routes/DiagramView.tsx index 5400ca5..f6dbfaf 100644 --- a/Website/routes/DiagramView.tsx +++ b/Website/routes/DiagramView.tsx @@ -1,10 +1,27 @@ 'use client'; -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useState } from 'react' import { dia, shapes, util } from '@joint/core' import { Groups } from "../generated/Data" import { EntityElement } from '@/components/diagram/entity/entity'; import debounce from 'lodash/debounce'; +import { + SidebarProvider, + Sidebar, + SidebarHeader, + SidebarContent, + SidebarFooter +} from '@/components/ui/sidebar'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { PanelLeft, ZoomIn, ZoomOut } from 'lucide-react'; +import { DiagramProvider, useDiagramContext } from '@/contexts/DiagramContext'; +import { DiagramCanvas } from '@/components/diagram/DiagramCanvas'; +import { GroupSelector } from '@/components/diagram/GroupSelector'; +import { EntityInfoPanel } from '@/components/diagram/EntityInfoPanel'; +import { DiagramResetButton } from '@/components/diagram/DiagramResetButton'; +import { ZoomCoordinateIndicator } from '@/components/diagram/ZoomCoordinateIndicator'; +import { calculateGridLayout, getDefaultLayoutOptions } from '@/components/diagram/GridLayoutManager'; interface IDiagramView {} @@ -13,13 +30,29 @@ const routerPadding = 16; const routerTries = 5000; const routerDirections = 90; -export default function DiagramView({ }: IDiagramView) { - const paperRef = useRef(null); - const graphInstance = useRef(new dia.Graph()); - +const DiagramContent: React.FC = () => { + const { + graph, + paper, + selectedGroup, + currentEntities, + zoom, + mousePosition, + selectGroup, + zoomIn, + zoomOut, + resetView + } = useDiagramContext(); + const [selectedKey, setSelectedKey] = useState(); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); - const entityData = Groups[1].Entities; + // Auto-select first group on mount + useEffect(() => { + if (Groups.length > 0 && !selectedGroup) { + selectGroup(Groups[0]); + } + }, [Groups, selectedGroup, selectGroup]); useEffect(() => { document.addEventListener('click', (e) => { @@ -33,39 +66,39 @@ export default function DiagramView({ }: IDiagramView) { setSelectedKey(schemaName); } }); - }, []) + }, []); useEffect(() => { - if (!paperRef.current) return; - if (!graphInstance?.current) return; - const paper = new dia.Paper({ - el: paperRef.current, - model: graphInstance?.current, - width: 1920, - height: 1080, - gridSize: 8, - background: { color: '#F8F9FA' }, - }); + if (!graph || !paper || !selectedGroup) return; + + // Clear existing elements + graph.clear(); + + // Calculate grid layout + const layoutOptions = getDefaultLayoutOptions(); + const layout = calculateGridLayout(currentEntities, layoutOptions); // Store entity elements and port maps by SchemaName for easy lookup const entityMap = new Map(); - // Entities - for (const entity of entityData) { + // Create entities in grid layout + currentEntities.forEach((entity, index) => { + const position = layout.positions[index] || { x: 50, y: 50 }; const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); const entityElement = new EntityElement({ - position: { x: 50, y: 50 }, + position, data: { entity, setSelectedKey } }); - entityElement.addTo(graphInstance.current); + entityElement.addTo(graph); entityMap.set(entity.SchemaName, { element: entityElement, portMap }); - } + }); util.nextFrame(() => { - const allElements = graphInstance.current.getElements(); + const allElements = graph.getElements(); const obstacles = allElements.map(el => el.getBBox().inflate(routerPadding)); - for (const entity of entityData) { + // Create relationships + for (const entity of currentEntities) { const entityInfo = entityMap.get(entity.SchemaName); if (!entityInfo) continue; const { portMap } = entityInfo; @@ -134,21 +167,21 @@ export default function DiagramView({ }: IDiagramView) { ] }); - link.addTo(graphInstance.current); + link.addTo(graph); } } } }); const reroute = debounce(() => { - const elements = graphInstance.current.getElements().filter(el => el.get('type') !== 'standard.Link'); + const elements = graph.getElements().filter(el => el.get('type') !== 'standard.Link'); const obstacles = elements.map(el => { const bbox = el.getBBox(); const inflated = bbox.inflate(routerPadding); return inflated; }); - graphInstance.current.getLinks().forEach(link => { + graph.getLinks().forEach(link => { link.router(routerType, { startDirections: ['left', 'right'], endDirections: ['left', 'right'], @@ -162,22 +195,125 @@ export default function DiagramView({ }: IDiagramView) { }); }); }, 100); - graphInstance.current.on('change:position change:size change:attrs.line', reroute); + graph.on('change:position change:size change:attrs.line', reroute); return () => { - paper.remove(); + graph.off('change:position change:size change:attrs.line', reroute); }; - }, [paperRef.current]); + }, [graph, paper, selectedGroup, currentEntities]); + + useEffect(() => { + if (!selectedKey || !graph) return; + console.log(graph.getLinks()) + // const links = graph.getLinks().filter(link => link.target().id === selectedKey); + }, [selectedKey, graph]); + + return ( + +
+ {/* Left Sidebar */} + + +

Diagram Tools

+
+ + {/* Group Selection */} + + + + + {/* Entity Information */} + + + + + {/* Diagram Controls */} +
+

Controls

+
+ + +
+ +
+
+ +
+ Zoom: {Math.round(zoom * 100)}% +
+
+
+ + {/* Main Content */} +
+ {/* Top Toolbar */} +
+
+
+

Data Model Diagram

+ +
+ Group: + + {selectedGroup?.Name || 'None'} + +
+ +
+ Entities: + + {currentEntities.length} + +
+
+
+ +
+
+
- useEffect(() =>{ - if (!selectedKey || !graphInstance.current) return; - console.log(graphInstance.current.getLinks()) - // const links = graphInstance.current.getLinks().filter(link => link.target().id === selectedKey); - }, [selectedKey]) + {/* Diagram Area */} + + {/* Zoom and Coordinate Indicator */} + + +
+
+
+ ); +}; +export default function DiagramView({ }: IDiagramView) { return ( - <> -
- + + + ); } From cdb76d96e21d04c5f2db887ebaf71b029cde9a0c Mon Sep 17 00:00:00 2001 From: boer Date: Sun, 29 Jun 2025 16:15:36 +0200 Subject: [PATCH 07/45] chore: more diagram work. Better grid layout, working on add attribute --- Website/.cursor/{ => rules}/ai-rules.md | 0 Website/.cursor/rules/rule.mdc | 6 + .../ai-rules/DIAGRAM_REFACTORING_PROMPT.md | 228 ------------------ Website/app/globals.css | 6 - .../components/diagram/AddAttributeModal.tsx | 183 ++++++++++++++ Website/components/diagram/BackgroundDots.ts | 38 +++ Website/components/diagram/DiagramCanvas.tsx | 19 +- .../components/diagram/EntityInfoPanel.tsx | 95 -------- .../components/diagram/GridLayoutManager.ts | 89 +++++-- Website/components/diagram/GroupSelector.tsx | 9 +- .../diagram/entity/EntityAttribute.ts | 30 ++- .../components/diagram/entity/EntityBody.ts | 3 +- Website/components/diagram/entity/entity.ts | 34 ++- Website/components/ui/scroll-area.tsx | 48 ---- Website/hooks/useDiagram.ts | 55 ++++- Website/routes/DiagramView.tsx | 149 +++++++++--- 16 files changed, 540 insertions(+), 452 deletions(-) rename Website/.cursor/{ => rules}/ai-rules.md (100%) create mode 100644 Website/.cursor/rules/rule.mdc delete mode 100644 Website/ai-rules/DIAGRAM_REFACTORING_PROMPT.md create mode 100644 Website/components/diagram/AddAttributeModal.tsx create mode 100644 Website/components/diagram/BackgroundDots.ts delete mode 100644 Website/components/diagram/EntityInfoPanel.tsx delete mode 100644 Website/components/ui/scroll-area.tsx diff --git a/Website/.cursor/ai-rules.md b/Website/.cursor/rules/ai-rules.md similarity index 100% rename from Website/.cursor/ai-rules.md rename to Website/.cursor/rules/ai-rules.md diff --git a/Website/.cursor/rules/rule.mdc b/Website/.cursor/rules/rule.mdc new file mode 100644 index 0000000..c336dfc --- /dev/null +++ b/Website/.cursor/rules/rule.mdc @@ -0,0 +1,6 @@ +--- +description: +globs: +alwaysApply: true +--- +Read the .cursor/rules/ai-rules.md file \ No newline at end of file diff --git a/Website/ai-rules/DIAGRAM_REFACTORING_PROMPT.md b/Website/ai-rules/DIAGRAM_REFACTORING_PROMPT.md deleted file mode 100644 index 25834cd..0000000 --- a/Website/ai-rules/DIAGRAM_REFACTORING_PROMPT.md +++ /dev/null @@ -1,228 +0,0 @@ -# Diagram Page Refactoring Prompt - -## Overview -Refactor the existing `routes/DiagramView.tsx` to implement a new diagram interface with enhanced functionality including a sidepane for group selection and diagram controls, and a redesigned diagram canvas with improved layout and styling. - -## Current State Analysis -The current implementation uses: -- JointJS for diagram rendering -- React Context for state management (`DiagramContext`) -- Custom hooks (`useDiagram`) for diagram operations -- shadcn/ui components for the interface -- Entity data from `generated/Data.ts` with multiple groups - -## Required Features - -### 1. Sidepane Implementation - -#### 1.a Group Selection -- **Location**: Left sidepane -- **Functionality**: - - Display all available groups from `Groups` array - - Allow user to select a group to display its entities - - Show group name and entity count - - Highlight currently selected group - - Default to first group if none selected - -#### 1.b Diagram Reset Control -- **Location**: Sidepane controls section -- **Functionality**: - - Reset button to return diagram to default view - - Reset zoom to 100% - - Reset pan position to center - - Clear any selections - -#### 1.c Entity Information Display -- **Location**: Sidepane information section -- **Functionality**: - - Show high-level statistics for current group - - Display total entity count - - Show relationship count - - Display group description if available - - Show entity list with basic info (name, type, description) - -### 2. Diagram Canvas Redesign - -#### 2.a Light Yellow Background with Black Dots -- **Background Color**: Light yellow (`#fef3c7` or similar) -- **Dot Pattern**: Small black dots in a grid pattern -- **Implementation**: Use CSS background pattern or JointJS background configuration - -#### 2.b Zoom and Pan Functionality -- **Zoom**: - - Mouse wheel zoom (already implemented) - - Zoom buttons in sidepane - - Zoom range: 0.1x to 3x - - Zoom to mouse position -- **Pan**: - - Click and drag on empty canvas area - - Smooth panning with visual feedback - - Pan limits to prevent elements from going off-screen - -#### 2.c Zoom and Coordinate Indicator -- **Location**: Bottom-right corner overlay -- **Display**: - - Current zoom level (e.g., "100%", "150%") - - Current mouse coordinates relative to diagram - - Format: "Zoom: 100% | X: 150, Y: 200" -- **Styling**: Semi-transparent background with readable text - -#### 2.d Grid Layout with Padding -- **Layout Algorithm**: - - Arrange entities in a grid pattern - - Calculate optimal grid size based on entity count - - Maintain consistent spacing between entities - - Center the grid on the canvas -- **Padding**: - - Minimum 50px padding between entities - - 100px padding from canvas edges - - Responsive to zoom level - -### 3. Preserve Existing Functionality - -#### 3.a Current Styling -- **Entity Elements**: Keep existing `EntityElement` styling -- **Links**: Maintain current link styling and routing -- **Colors**: Preserve existing color scheme for entities and relationships -- **Typography**: Keep existing font styles and sizes - -#### 3.b Links and Relationships -- **Routing**: Maintain Manhattan routing algorithm -- **Styling**: Keep current link colors (`#6366f1`) -- **Labels**: Preserve relationship labels (`*` and `+`) -- **Arrow Markers**: Keep existing arrow styling -- **Debouncing**: Maintain existing reroute debouncing - -## Technical Implementation Requirements - -### State Management -```typescript -interface DiagramState { - selectedGroup: GroupType | null; - currentEntities: EntityType[]; - zoom: number; - panPosition: { x: number; y: number }; - mousePosition: { x: number; y: number } | null; -} -``` - -### New Components to Create -1. **GroupSelector**: Component for group selection in sidepane -2. **EntityInfoPanel**: Component for displaying entity information -3. **DiagramResetButton**: Component for reset functionality -4. **ZoomCoordinateIndicator**: Component for zoom/coordinate display -5. **GridLayoutManager**: Utility for calculating grid positions - -### File Structure Changes -``` -components/ -├── diagram/ -│ ├── GroupSelector.tsx (new) -│ ├── EntityInfoPanel.tsx (new) -│ ├── DiagramResetButton.tsx (new) -│ ├── ZoomCoordinateIndicator.tsx (new) -│ └── GridLayoutManager.ts (new) -``` - -### Context Updates -Extend `DiagramContext` to include: -- Group selection state -- Grid layout calculations -- Mouse position tracking -- Reset functionality - -## Implementation Guidelines - -### 1. Follow Existing Patterns -- Use the established `DiagramContext` pattern -- Follow the custom hook structure from `useDiagram` -- Maintain TypeScript strict typing -- Use shadcn/ui components consistently - -### 2. Performance Considerations -- Use refs for values that don't need re-renders -- Implement proper cleanup for event listeners -- Use debouncing for expensive operations -- Optimize grid layout calculations - -### 3. Responsive Design -- Ensure sidepane is collapsible -- Make diagram canvas responsive to window size -- Handle different screen sizes appropriately -- Maintain usability on smaller screens - -### 4. Accessibility -- Add proper ARIA labels -- Ensure keyboard navigation works -- Provide screen reader support -- Maintain focus management - -## Code Quality Requirements - -### 1. TypeScript -- Strict typing for all new components -- Proper interface definitions -- Type guards where necessary -- No `any` types - -### 2. Error Handling -- Graceful handling of missing data -- Proper error boundaries -- User-friendly error messages -- Fallback states - -### 3. Testing Considerations -- Components should be testable -- Mock JointJS for testing -- Test state changes -- Test user interactions - -## Success Criteria - -### Functional Requirements -- [ ] Group selection works correctly -- [ ] Diagram reset functionality works -- [ ] Entity information displays properly -- [ ] Grid layout arranges entities correctly -- [ ] Zoom and pan work smoothly -- [ ] Coordinate indicator updates in real-time -- [ ] Background pattern displays correctly - -### Performance Requirements -- [ ] No performance degradation from current implementation -- [ ] Smooth zoom and pan operations -- [ ] Responsive UI interactions -- [ ] Efficient grid layout calculations - -### User Experience Requirements -- [ ] Intuitive group selection -- [ ] Clear visual feedback for interactions -- [ ] Consistent styling with existing components -- [ ] Smooth transitions and animations - -## Migration Strategy - -### Phase 1: Sidepane Implementation -1. Create new sidepane components -2. Implement group selection logic -3. Add entity information display -4. Integrate with existing context - -### Phase 2: Canvas Redesign -1. Update background styling -2. Implement grid layout algorithm -3. Add zoom/coordinate indicator -4. Enhance zoom and pan functionality - -### Phase 3: Integration and Testing -1. Integrate all components -2. Test all functionality -3. Optimize performance -4. Fix any issues - -## Notes -- Preserve all existing functionality while adding new features -- Maintain backward compatibility with existing data structure -- Follow the established code patterns and conventions -- Ensure the refactored code is maintainable and extensible -- Document any significant changes or new patterns introduced \ No newline at end of file diff --git a/Website/app/globals.css b/Website/app/globals.css index 6a39d63..627a47d 100644 --- a/Website/app/globals.css +++ b/Website/app/globals.css @@ -91,10 +91,4 @@ body { .striped tr:nth-child(even) { @apply bg-gray-100 } - - .dotted-background { - background-color: #fef3c7; - background-image: radial-gradient(circle, #000 1px, transparent 1px); - background-size: 20px 20px; - } } \ No newline at end of file diff --git a/Website/components/diagram/AddAttributeModal.tsx b/Website/components/diagram/AddAttributeModal.tsx new file mode 100644 index 0000000..592ec2f --- /dev/null +++ b/Website/components/diagram/AddAttributeModal.tsx @@ -0,0 +1,183 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + Type, + Calendar, + Hash, + Search, + DollarSign, + ToggleLeft, + FileText, + List, + Activity, + Plus +} from 'lucide-react'; +import { AttributeType } from '@/lib/Types'; + +export interface AddAttributeModalProps { + isOpen: boolean; + onClose: () => void; + onAddAttribute: (attribute: AttributeType) => void; + entityName?: string; + availableAttributes: AttributeType[]; + visibleAttributes: AttributeType[]; +} + +const getAttributeIcon = (attributeType: string) => { + switch (attributeType) { + case 'StringAttribute': return Type; + case 'IntegerAttribute': return Hash; + case 'DecimalAttribute': return DollarSign; + case 'DateTimeAttribute': return Calendar; + case 'BooleanAttribute': return ToggleLeft; + case 'ChoiceAttribute': return List; + case 'LookupAttribute': return Search; + case 'FileAttribute': return FileText; + case 'StatusAttribute': return Activity; + default: return Type; + } +}; + +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 AddAttributeModal: 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 + + {entityName ? `Select an attribute from "${entityName}" to add to the diagram.` : 'Select an attribute to add to the diagram.'} + + + +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search by attribute name..." + /> +
+ + {/* Available Attributes */} +
+ +
+
+ {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)} + > + +
+
+ +
+
+
+ {attribute.DisplayName} +
+
+ {typeLabel} • {attribute.SchemaName} +
+
+ {attribute.Description && ( + + +
+ ? +
+
+ +

{attribute.Description}

+
+
+ )} +
+
+
+ ); + })} +
+ )} +
+
+
+ + + + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagram/BackgroundDots.ts b/Website/components/diagram/BackgroundDots.ts new file mode 100644 index 0000000..69c2eec --- /dev/null +++ b/Website/components/diagram/BackgroundDots.ts @@ -0,0 +1,38 @@ +import { dia } from '@joint/core'; + +export const createBackgroundDots = (graph: dia.Graph, paper: dia.Paper) => { + // Remove existing background dots + graph.getElements().forEach(el => { + if (el.get('type') === 'background.dots') { + el.remove(); + } + }); + + const paperSize = paper.getComputedSize(); + const dotSpacing = 20; // Base spacing between dots + const dotRadius = 1; // Smaller, less prominent dots + + // Create dots across the entire paper area + for (let x = 0; x < paperSize.width; x += dotSpacing) { + for (let y = 0; y < paperSize.height; y += dotSpacing) { + const dot = new dia.Element({ + type: 'background.dots', + position: { x, y }, + size: { width: dotRadius * 2, height: dotRadius * 2 }, + attrs: { + body: { + cx: dotRadius, + cy: dotRadius, + r: dotRadius, + fill: '#d1d5db', // Light gray, less prominent + stroke: 'none' + } + }, + markup: [ + { tagName: 'circle', selector: 'body' } + ] + }); + dot.addTo(graph); + } + } +}; \ No newline at end of file diff --git a/Website/components/diagram/DiagramCanvas.tsx b/Website/components/diagram/DiagramCanvas.tsx index 6b38049..ccea279 100644 --- a/Website/components/diagram/DiagramCanvas.tsx +++ b/Website/components/diagram/DiagramCanvas.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState } from 'react'; import { useDiagramContext } from '@/contexts/DiagramContext'; interface DiagramCanvasProps { @@ -7,6 +7,7 @@ interface DiagramCanvasProps { export const DiagramCanvas: React.FC = ({ children }) => { const canvasRef = useRef(null); + const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); const { isPanning, initializePaper, @@ -17,18 +18,30 @@ export const DiagramCanvas: React.FC = ({ children }) => { if (canvasRef.current) { const paper = initializePaper(canvasRef.current, { background: { - color: '#fef3c7' // Light yellow background + color: 'transparent' // Make paper background transparent to show CSS dots } }); + // Track container dimensions + const updateDimensions = () => { + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + setContainerDimensions({ width: rect.width, height: rect.height }); + } + }; + + updateDimensions(); + window.addEventListener('resize', updateDimensions); + return () => { + window.removeEventListener('resize', updateDimensions); destroyPaper(); }; } }, [initializePaper, destroyPaper]); return ( -
+
= ({ - selectedGroup -}) => { - if (!selectedGroup) { - return ( -
- -

Select a group to view information

-
- ); - } - - const entityCount = selectedGroup.Entities.length; - const relationshipCount = selectedGroup.Entities.reduce( - (total, entity) => total + entity.Relationships.length, - 0 - ); - - return ( -
-
-

Group Information

- -
- - - - - - {selectedGroup.Name} - - - {/* Statistics */} -
-
-
- {entityCount} -
-
- {entityCount === 1 ? 'Entity' : 'Entities'} -
-
-
-
- {relationshipCount} -
-
- {relationshipCount === 1 ? 'Relationship' : 'Relationships'} -
-
-
- - - - {/* Entity List */} -
-
- - Entities -
- -
- {selectedGroup.Entities.map((entity) => ( -
-
{entity.DisplayName}
-
- {entity.Attributes.length} attributes -
- {entity.Description && ( -
- {entity.Description} -
- )} -
- ))} -
-
-
-
-
- ); -}; \ No newline at end of file diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts index e1e3a57..2af8938 100644 --- a/Website/components/diagram/GridLayoutManager.ts +++ b/Website/components/diagram/GridLayoutManager.ts @@ -1,4 +1,5 @@ import { EntityType } from '@/lib/Types'; +import { EntityElement } from '@/components/diagram/entity/entity'; export interface GridLayoutOptions { containerWidth: number; @@ -23,7 +24,22 @@ export interface GridLayoutResult { } /** - * Calculates optimal grid layout for entities + * Calculates the actual height of an entity based on its visible attributes + */ +export const calculateEntityHeight = (entity: EntityType): number => { + 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 */ export const calculateGridLayout = ( entities: EntityType[], @@ -45,9 +61,46 @@ export const calculateGridLayout = ( const availableWidth = containerWidth - (margin * 2); const availableHeight = containerHeight - (margin * 2); - // Calculate how many entities can fit in a row - const entitiesPerRow = Math.floor(availableWidth / (entityWidth + padding)); - const columns = Math.max(1, entitiesPerRow); + // Calculate aspect ratio of available space + const aspectRatio = availableWidth / availableHeight; + + console.log('Layout calculation:', { + availableWidth, + availableHeight, + aspectRatio, + entityWidth, + entityHeight, + padding, + entityCount: entities.length + }); + + // Determine optimal number of columns based on aspect ratio and entity count + let columns: number; + if (aspectRatio > 1.5) { + // Wide screen - prefer more columns + columns = Math.ceil(Math.sqrt(entities.length * aspectRatio)); + } else if (aspectRatio < 0.8) { + // Tall screen - prefer fewer columns + columns = Math.ceil(Math.sqrt(entities.length / aspectRatio)); + } else { + // Square-ish screen - balanced approach + columns = Math.ceil(Math.sqrt(entities.length)); + } + + // Ensure we don't exceed available width + const maxColumnsByWidth = Math.floor(availableWidth / (entityWidth + padding)); + columns = Math.min(columns, maxColumnsByWidth); + + // Ensure we have at least 1 column + columns = Math.max(1, columns); + + console.log('Column calculation:', { + initialColumns: columns, + maxColumnsByWidth, + finalColumns: columns + }); + + // Calculate rows needed const rows = Math.ceil(entities.length / columns); // Calculate grid dimensions @@ -85,20 +138,12 @@ export const calculateGridLayout = ( */ export const estimateEntityDimensions = (entity: EntityType): { width: number; height: number } => { // Base dimensions - const baseWidth = 200; - const baseHeight = 120; - - // Adjust based on number of attributes - const attributeCount = entity.Attributes.length; - const heightAdjustment = Math.min(attributeCount * 8, 80); // Max 80px additional height - - // Adjust based on name length - const nameLength = entity.DisplayName.length; - const widthAdjustment = Math.min(nameLength * 2, 60); // Max 60px additional width + const baseWidth = 480; // Match the entity width used in EntityElement + const height = calculateEntityHeight(entity); // Use actual calculated height return { - width: baseWidth + widthAdjustment, - height: baseHeight + heightAdjustment + width: baseWidth, + height: height }; }; @@ -106,10 +151,10 @@ export const estimateEntityDimensions = (entity: EntityType): { width: number; h * Gets default layout options */ export const getDefaultLayoutOptions = (): GridLayoutOptions => ({ - containerWidth: 1200, - containerHeight: 800, - entityWidth: 200, - entityHeight: 120, - padding: 50, - margin: 100 + 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 }); \ No newline at end of file diff --git a/Website/components/diagram/GroupSelector.tsx b/Website/components/diagram/GroupSelector.tsx index efcacdd..69b9603 100644 --- a/Website/components/diagram/GroupSelector.tsx +++ b/Website/components/diagram/GroupSelector.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { GroupType } from '@/lib/Types'; import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { FolderOpen, Folder } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -28,7 +27,7 @@ export const GroupSelector: React.FC = ({ - +
{groups.map((group) => { const isSelected = selectedGroup?.Name === group.Name; @@ -51,8 +50,8 @@ export const GroupSelector: React.FC = ({ )} -
-
+
+
{group.Name}
@@ -68,7 +67,7 @@ export const GroupSelector: React.FC = ({ ); })}
- +
); }; \ No newline at end of file diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/entity/EntityAttribute.ts index 55cb384..dbd0db4 100644 --- a/Website/components/diagram/entity/EntityAttribute.ts +++ b/Website/components/diagram/entity/EntityAttribute.ts @@ -5,14 +5,40 @@ interface IEntityAttribute { isKey: boolean; } -export const EntityAttribute = ({ attribute, isKey }: IEntityAttribute): string => { +export const EntityAttribute = ({ attribute, isKey, isAddButton = false }: IEntityAttribute & { isAddButton?: boolean }): string => { + if (isAddButton) { + return ` + + `; + } + + 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 = `` + } + return ` `; }; \ No newline at end of file diff --git a/Website/components/diagram/entity/EntityBody.ts b/Website/components/diagram/entity/EntityBody.ts index 0f631a1..397dac4 100644 --- a/Website/components/diagram/entity/EntityBody.ts +++ b/Website/components/diagram/entity/EntityBody.ts @@ -13,7 +13,7 @@ export function EntityBody({ entity, visibleItems }: IEntityBody): string { : '/vercel.svg'; return ` -
+
@@ -28,6 +28,7 @@ export function EntityBody({ entity, visibleItems }: IEntityBody): string {
${visibleItems.map((attribute, i) => (EntityAttribute({ attribute, isKey: i == 0 }))).join('')} + ${EntityAttribute({ attribute: { DisplayName: '', SchemaName: '', AttributeType: 'GenericAttribute' } as any, isKey: false, isAddButton: true })}
`; diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index 24780b4..98285c2 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -17,15 +17,25 @@ export class EntityElement extends dia.Element { static getVisibleItemsAndPorts(entity: EntityType) { const itemHeight = 32; const headerHeight = 80; - const maxHeight = 360; // default size - const availableHeight = maxHeight - headerHeight; + const addButtonHeight = 32; // Space for add button + const maxHeight = 400; // increased from 360 to provide more space + const availableHeight = maxHeight - headerHeight - addButtonHeight; const maxVisibleItems = Math.floor(availableHeight / itemHeight); + + // Show key and important attributes (lookup, string, integer, etc.) + const importantAttributes = entity.Attributes.filter(attr => + attr.AttributeType === "LookupAttribute" || + attr.AttributeType === "StringAttribute" || + attr.AttributeType === "IntegerAttribute" || + attr.AttributeType === "DecimalAttribute" || + attr.AttributeType === "DateTimeAttribute" || + attr.AttributeType === "BooleanAttribute" || + attr.AttributeType === "ChoiceAttribute" + ).filter(attr => attr.IsCustomAttribute); + const visibleItems = [ - { DisplayName: "Key", SchemaName: entity.SchemaName + "id" } as AttributeType, - ...entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" && - attr.IsCustomAttribute - ) + entity.Attributes.find(attr => attr.IsPrimaryId) ?? { DisplayName: "Key", SchemaName: entity.SchemaName + "id" } as AttributeType, + ...importantAttributes ].slice(0, maxVisibleItems); // Map SchemaName to port name @@ -50,9 +60,11 @@ export class EntityElement extends dia.Element { const itemHeight = 28; const itemYSpacing = 8; + const addButtonHeight = 32; // Space for add button const startY = 80 + itemYSpacing * 2; - const height = startY + visibleItems.length * (itemHeight + itemYSpacing); + // Calculate height including the add button + const height = startY + visibleItems.length * (itemHeight + itemYSpacing) + addButtonHeight + itemYSpacing; const leftPorts: dia.Element.Port[] = []; const rightPorts: dia.Element.Port[] = []; @@ -76,14 +88,16 @@ export class EntityElement extends dia.Element { } }; - // Heuristic: If it's a LookupAttribute, treat as outgoing (right); otherwise, incoming (left) + // 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 { + } 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', { diff --git a/Website/components/ui/scroll-area.tsx b/Website/components/ui/scroll-area.tsx deleted file mode 100644 index c9e741f..0000000 --- a/Website/components/ui/scroll-area.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client" - -import * as React from "react" -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" - -import { cn } from "@/lib/utils" - -const ScrollArea = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - {children} - - - - -)) -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName - -const ScrollBar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, orientation = "vertical", ...props }, ref) => ( - - - -)) -ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName - -export { ScrollArea, ScrollBar } \ No newline at end of file diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 3dd3003..0c3c032 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -1,6 +1,8 @@ import { useRef, useState, useCallback, useEffect } from 'react'; import { dia } from '@joint/core'; -import { GroupType, EntityType } from '@/lib/Types'; +import { GroupType, EntityType, AttributeType } from '@/lib/Types'; +import { createBackgroundDots } from '@/components/diagram/BackgroundDots'; +import { EntityElement } from '@/components/diagram/entity/entity'; export interface DiagramState { zoom: number; @@ -28,6 +30,7 @@ export interface DiagramActions { selectGroup: (group: GroupType) => void; updateMousePosition: (position: { x: number; y: number } | null) => void; updatePanPosition: (position: { x: number; y: number }) => void; + addAttributeToEntity: (entitySchemaName: string, attribute: AttributeType) => void; } export const useDiagram = (): DiagramState & DiagramActions => { @@ -143,13 +146,52 @@ export const useDiagram = (): DiagramState & DiagramActions => { setPanPosition(position); }, []); + const addAttributeToEntity = useCallback((entitySchemaName: string, attribute: AttributeType) => { + if (!graphRef.current) return; + + // Find the entity element in the graph + const entityElement = graphRef.current.getElements().find(el => + el.get('type') === 'delegate.entity' && + el.get('data')?.entity?.SchemaName === entitySchemaName + ); + + if (entityElement) { + // Update the entity's data to include the new attribute + const currentEntity = entityElement.get('data').entity; + const updatedEntity = { + ...currentEntity, + Attributes: [...currentEntity.Attributes, attribute] + }; + + // Update the element's data + entityElement.set('data', { entity: updatedEntity }); + + // Trigger the updateAttributes method to re-render the entity + const entityElementTyped = entityElement as EntityElement; + if (entityElementTyped.updateAttributes) { + entityElementTyped.updateAttributes(updatedEntity); + } + + // Update the currentEntities state to reflect the change + setCurrentEntities(prev => + prev.map(entity => + entity.SchemaName === entitySchemaName + ? { ...entity, Attributes: [...entity.Attributes, attribute] } + : entity + ) + ); + + console.log(`Added attribute ${attribute.DisplayName} to entity ${entitySchemaName}`); + } + }, []); + const initializePaper = useCallback((container: HTMLElement, options: any = {}) => { // Create graph if it doesn't exist if (!graphRef.current) { graphRef.current = new dia.Graph(); } - // Create paper with dotted background + // Create paper with light amber background const paper = new dia.Paper({ el: container, model: graphRef.current, @@ -157,7 +199,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { height: '100%', gridSize: 8, background: { - color: '#fef3c7', // Light yellow background + color: '#fef3c7', // Light amber background ...options.background }, ...options @@ -165,6 +207,12 @@ export const useDiagram = (): DiagramState & DiagramActions => { paperRef.current = paper; + // Add dotted background that scales with zoom + createBackgroundDots(graphRef.current!, paper); + + // Re-add background dots when paper size changes (e.g., on resize) + paper.on('resize', () => createBackgroundDots(graphRef.current!, paper)); + // Setup event listeners paper.on('blank:pointerdown', () => { updatePanningDisplay(true); @@ -295,5 +343,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { selectGroup, updateMousePosition, updatePanPosition, + addAttributeToEntity, }; }; \ No newline at end of file diff --git a/Website/routes/DiagramView.tsx b/Website/routes/DiagramView.tsx index f6dbfaf..cb553e9 100644 --- a/Website/routes/DiagramView.tsx +++ b/Website/routes/DiagramView.tsx @@ -18,10 +18,12 @@ import { PanelLeft, ZoomIn, ZoomOut } from 'lucide-react'; import { DiagramProvider, useDiagramContext } from '@/contexts/DiagramContext'; import { DiagramCanvas } from '@/components/diagram/DiagramCanvas'; import { GroupSelector } from '@/components/diagram/GroupSelector'; -import { EntityInfoPanel } from '@/components/diagram/EntityInfoPanel'; import { DiagramResetButton } from '@/components/diagram/DiagramResetButton'; import { ZoomCoordinateIndicator } from '@/components/diagram/ZoomCoordinateIndicator'; -import { calculateGridLayout, getDefaultLayoutOptions } from '@/components/diagram/GridLayoutManager'; +import { AddAttributeModal } from '@/components/diagram/AddAttributeModal'; +import { calculateGridLayout, getDefaultLayoutOptions, calculateEntityHeight } from '@/components/diagram/GridLayoutManager'; +import { AttributeType } from '@/lib/Types'; +import { createBackgroundDots } from '@/components/diagram/BackgroundDots'; interface IDiagramView {} @@ -41,11 +43,15 @@ const DiagramContent: React.FC = () => { selectGroup, zoomIn, zoomOut, - resetView + resetView, + fitToScreen, + addAttributeToEntity } = useDiagramContext(); const [selectedKey, setSelectedKey] = useState(); const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [isAddAttributeModalOpen, setIsAddAttributeModalOpen] = useState(false); + const [selectedEntityForAttribute, setSelectedEntityForAttribute] = useState(); // Auto-select first group on mount useEffect(() => { @@ -57,6 +63,19 @@ const DiagramContent: React.FC = () => { useEffect(() => { document.addEventListener('click', (e) => { const target = (e.target as HTMLElement).closest('button[data-schema-name]') as HTMLElement; + const addButton = (e.target as HTMLElement).closest('button[data-add-attribute]') as HTMLElement; + + if (addButton) { + // Find the entity this add button belongs to + const entityElement = addButton.closest('[data-entity-schema]') as HTMLElement; + if (entityElement) { + const entitySchema = entityElement.dataset.entitySchema; + setSelectedEntityForAttribute(entitySchema); + setIsAddAttributeModalOpen(true); + } + return; + } + if (!target) return; const schemaName = target.dataset.schemaName!; @@ -74,9 +93,41 @@ const DiagramContent: React.FC = () => { // Clear existing elements graph.clear(); + // Recreate background dots after clearing + createBackgroundDots(graph, paper); + // Calculate grid layout const layoutOptions = getDefaultLayoutOptions(); - const layout = calculateGridLayout(currentEntities, layoutOptions); + + // 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 + }; + + // Calculate actual heights for each entity + const entityHeights = currentEntities.map(entity => calculateEntityHeight(entity)); + const maxEntityHeight = Math.max(...entityHeights, layoutOptions.entityHeight); + + console.log('Entity heights:', entityHeights); + console.log('Max entity height:', maxEntityHeight); + console.log('Container dimensions:', { width: actualContainerWidth, height: actualContainerHeight }); + + // Use the maximum height for layout calculation to ensure proper spacing + const adjustedLayoutOptions = { + ...updatedLayoutOptions, + entityHeight: maxEntityHeight + }; + + const layout = calculateGridLayout(currentEntities, adjustedLayoutOptions); + + console.log('Layout result:', { columns: layout.columns, rows: layout.rows, positions: layout.positions.length }); // Store entity elements and port maps by SchemaName for easy lookup const entityMap = new Map(); @@ -95,7 +146,9 @@ const DiagramContent: React.FC = () => { util.nextFrame(() => { const allElements = graph.getElements(); - const obstacles = allElements.map(el => el.getBBox().inflate(routerPadding)); + // Filter out background dots from obstacles - only use actual entity elements + const entityElements = allElements.filter(el => el.get('type') !== 'background.dots'); + const obstacles = entityElements.map(el => el.getBBox().inflate(routerPadding)); // Create relationships for (const entity of currentEntities) { @@ -119,6 +172,7 @@ const DiagramContent: React.FC = () => { if (!sourcePort || !targetPort) continue; + // Use a filled arrow for 'many' side, and a small circle for 'one' side const link = new shapes.standard.Link({ source: { id: sourceId, port: sourcePort }, target: { id: targetId, port: targetPort }, @@ -140,7 +194,17 @@ const DiagramContent: React.FC = () => { attrs: { line: { stroke: '#6366f1', - strokeWidth: 1, + strokeWidth: 2, + sourceMarker: { + type: 'ellipse', + cx: 0, + cy: 0, + rx: 4, + ry: 4, + fill: '#fff', + stroke: '#6366f1', + strokeWidth: 2 + }, targetMarker: { type: 'path', d: 'M 10 -5 L 0 0 L 10 5 Z', @@ -148,23 +212,8 @@ const DiagramContent: React.FC = () => { stroke: '#6366f1' } } - }, - labels: [ - { - position: 0.05, - attrs: { - text: { text: '*', fontSize: 18, fill: '#6366f1', fontWeight: 'bold' }, - rect: { fill: 'white', stroke: 'none' } - } - }, - { - position: 0.95, - attrs: { - text: { text: '+', fontSize: 18, fill: '#6366f1', fontWeight: 'bold' }, - rect: { fill: 'white', stroke: 'none' } - } - } - ] + } + // No labels }); link.addTo(graph); @@ -175,7 +224,9 @@ const DiagramContent: React.FC = () => { const reroute = debounce(() => { const elements = graph.getElements().filter(el => el.get('type') !== 'standard.Link'); - const obstacles = elements.map(el => { + // Filter out background dots from obstacles - only use actual entity elements + const entityElements = elements.filter(el => el.get('type') !== 'background.dots'); + const obstacles = entityElements.map(el => { const bbox = el.getBBox(); const inflated = bbox.inflate(routerPadding); return inflated; @@ -197,6 +248,11 @@ const DiagramContent: React.FC = () => { }, 100); graph.on('change:position change:size change:attrs.line', reroute); + // Auto-fit to screen after a short delay to ensure all elements are rendered + setTimeout(() => { + fitToScreen(); + }, 200); + return () => { graph.off('change:position change:size change:attrs.line', reroute); }; @@ -208,6 +264,36 @@ const DiagramContent: React.FC = () => { // const links = graph.getLinks().filter(link => link.target().id === selectedKey); }, [selectedKey, graph]); + useEffect(() => { + if (!paper) return; + // Add link click handler + const onLinkClick = (linkView: any, evt: any) => { + evt.stopPropagation(); + // Placeholder: show relationship info and hide option coming soon! + alert('Relationship info and hide option coming soon!'); + }; + paper.on('link:pointerclick', onLinkClick); + return () => { + paper.off('link:pointerclick', onLinkClick); + }; + }, [paper]); + + const handleAddAttribute = (attribute: AttributeType) => { + if (!selectedEntityForAttribute) return; + addAttributeToEntity(selectedEntityForAttribute, attribute); + setIsAddAttributeModalOpen(false); + setSelectedEntityForAttribute(undefined); + }; + + // Find the entity display name for the modal + const selectedEntity = currentEntities.find(entity => entity.SchemaName === selectedEntityForAttribute); + const selectedEntityName = selectedEntity?.DisplayName; + + // Get available and visible attributes for the selected entity + const availableAttributes = selectedEntity?.Attributes || []; + const visibleAttributes = selectedEntity ? + EntityElement.getVisibleItemsAndPorts(selectedEntity).visibleItems : []; + return (
@@ -226,11 +312,6 @@ const DiagramContent: React.FC = () => { - {/* Entity Information */} - - - - {/* Diagram Controls */}

Controls

@@ -306,6 +387,16 @@ const DiagramContent: React.FC = () => {
+ + {/* Add Attribute Modal */} + setIsAddAttributeModalOpen(false)} + onAddAttribute={handleAddAttribute} + entityName={selectedEntityName} + availableAttributes={availableAttributes} + visibleAttributes={visibleAttributes} + />
); }; From 932661328a9a41cf330f45a0ff07b8cb7f2fbbc6 Mon Sep 17 00:00:00 2001 From: boer Date: Sun, 29 Jun 2025 17:53:37 +0200 Subject: [PATCH 08/45] chore: add attribute working --- .../components/diagram/AddAttributeModal.tsx | 2 +- .../components/diagram/GridLayoutManager.ts | 16 ----- .../diagram/entity/EntityAttribute.ts | 2 +- Website/components/diagram/entity/entity.ts | 43 ++++++----- Website/hooks/useDiagram.ts | 71 +++++++++++++++---- Website/lib/Types.ts | 1 + Website/routes/DiagramView.tsx | 9 +-- 7 files changed, 83 insertions(+), 61 deletions(-) diff --git a/Website/components/diagram/AddAttributeModal.tsx b/Website/components/diagram/AddAttributeModal.tsx index 592ec2f..3622c10 100644 --- a/Website/components/diagram/AddAttributeModal.tsx +++ b/Website/components/diagram/AddAttributeModal.tsx @@ -95,7 +95,7 @@ export const AddAttributeModal: React.FC = ({ - Add Existing Attribute + Add Existing Attribute ({availableAttributes.length}) {entityName ? `Select an attribute from "${entityName}" to add to the diagram.` : 'Select an attribute to add to the diagram.'} diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts index 2af8938..cacdfae 100644 --- a/Website/components/diagram/GridLayoutManager.ts +++ b/Website/components/diagram/GridLayoutManager.ts @@ -64,16 +64,6 @@ export const calculateGridLayout = ( // Calculate aspect ratio of available space const aspectRatio = availableWidth / availableHeight; - console.log('Layout calculation:', { - availableWidth, - availableHeight, - aspectRatio, - entityWidth, - entityHeight, - padding, - entityCount: entities.length - }); - // Determine optimal number of columns based on aspect ratio and entity count let columns: number; if (aspectRatio > 1.5) { @@ -94,12 +84,6 @@ export const calculateGridLayout = ( // Ensure we have at least 1 column columns = Math.max(1, columns); - console.log('Column calculation:', { - initialColumns: columns, - maxColumnsByWidth, - finalColumns: columns - }); - // Calculate rows needed const rows = Math.ceil(entities.length / columns); diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/entity/EntityAttribute.ts index dbd0db4..1cb69fa 100644 --- a/Website/components/diagram/entity/EntityAttribute.ts +++ b/Website/components/diagram/entity/EntityAttribute.ts @@ -8,7 +8,7 @@ interface IEntityAttribute { export const EntityAttribute = ({ attribute, isKey, isAddButton = false }: IEntityAttribute & { isAddButton?: boolean }): string => { if (isAddButton) { return ` - diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index 98285c2..db4bc09 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -15,28 +15,26 @@ export class EntityElement extends dia.Element { } static getVisibleItemsAndPorts(entity: EntityType) { - const itemHeight = 32; - const headerHeight = 80; - const addButtonHeight = 32; // Space for add button - const maxHeight = 400; // increased from 360 to provide more space - const availableHeight = maxHeight - headerHeight - addButtonHeight; - const maxVisibleItems = Math.floor(availableHeight / itemHeight); + // Get the primary key attribute + const primaryKeyAttribute = entity.Attributes.find(attr => attr.IsPrimaryId) ?? + { DisplayName: "Key", SchemaName: entity.SchemaName + "id" } as AttributeType; - // Show key and important attributes (lookup, string, integer, etc.) - const importantAttributes = entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" || - attr.AttributeType === "StringAttribute" || - attr.AttributeType === "IntegerAttribute" || - attr.AttributeType === "DecimalAttribute" || - attr.AttributeType === "DateTimeAttribute" || - attr.AttributeType === "BooleanAttribute" || - attr.AttributeType === "ChoiceAttribute" - ).filter(attr => attr.IsCustomAttribute); + // Get custom lookup attributes (initially visible) + const customLookupAttributes = entity.Attributes.filter(attr => + attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute + ); + // Get manually added attributes (stored in entity metadata) + const manuallyAddedAttributes = entity.Attributes.filter(attr => + (entity as any).manuallyAddedAttributes?.includes(attr.SchemaName) + ); + + // Combine primary key, custom lookup attributes, and manually added attributes const visibleItems = [ - entity.Attributes.find(attr => attr.IsPrimaryId) ?? { DisplayName: "Key", SchemaName: entity.SchemaName + "id" } as AttributeType, - ...importantAttributes - ].slice(0, maxVisibleItems); + primaryKeyAttribute, + ...customLookupAttributes, + ...manuallyAddedAttributes + ]; // Map SchemaName to port name const portMap: Record = {}; @@ -61,11 +59,12 @@ export class EntityElement extends dia.Element { const itemHeight = 28; const itemYSpacing = 8; const addButtonHeight = 32; // Space for add button - const startY = 80 + itemYSpacing * 2; + const headerHeight = 80; + const startY = headerHeight + itemYSpacing * 2; - // Calculate height including the add button + // Calculate height dynamically based on number of visible items const height = startY + visibleItems.length * (itemHeight + itemYSpacing) + addButtonHeight + itemYSpacing; - + const leftPorts: dia.Element.Port[] = []; const rightPorts: dia.Element.Port[] = []; diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 0c3c032..35022df 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -39,6 +39,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { const zoomRef = useRef(1); const isPanningRef = useRef(false); const cleanupRef = useRef<(() => void) | null>(null); + const isAddingAttributeRef = useRef(false); const [zoom, setZoomState] = useState(1); const [isPanning, setIsPanningState] = useState(false); @@ -147,10 +148,22 @@ export const useDiagram = (): DiagramState & DiagramActions => { }, []); const addAttributeToEntity = useCallback((entitySchemaName: string, attribute: AttributeType) => { - if (!graphRef.current) return; + // Prevent double additions + if (isAddingAttributeRef.current) { + return; + } + + isAddingAttributeRef.current = true; + + if (!graphRef.current) { + isAddingAttributeRef.current = false; + return; + } // Find the entity element in the graph - const entityElement = graphRef.current.getElements().find(el => + const allElements = graphRef.current.getElements(); + + const entityElement = allElements.find(el => el.get('type') === 'delegate.entity' && el.get('data')?.entity?.SchemaName === entitySchemaName ); @@ -158,10 +171,33 @@ export const useDiagram = (): DiagramState & DiagramActions => { if (entityElement) { // Update the entity's data to include the new attribute const currentEntity = entityElement.get('data').entity; - const updatedEntity = { - ...currentEntity, - Attributes: [...currentEntity.Attributes, attribute] - }; + + // Check if attribute already exists in the entity + const attributeExists = currentEntity.Attributes.some((attr: AttributeType) => + attr.SchemaName === attribute.SchemaName + ); + + let updatedEntity; + if (attributeExists) { + // Attribute already exists, just mark it as manually added + updatedEntity = { + ...currentEntity, + manuallyAddedAttributes: [ + ...(currentEntity.manuallyAddedAttributes || []), + ...(currentEntity.manuallyAddedAttributes?.includes(attribute.SchemaName) ? [] : [attribute.SchemaName]) + ] + }; + } else { + // Attribute doesn't exist, add it and mark as manually added + updatedEntity = { + ...currentEntity, + Attributes: [...currentEntity.Attributes, attribute], + manuallyAddedAttributes: [ + ...(currentEntity.manuallyAddedAttributes || []), + attribute.SchemaName + ] + }; + } // Update the element's data entityElement.set('data', { entity: updatedEntity }); @@ -173,16 +209,25 @@ export const useDiagram = (): DiagramState & DiagramActions => { } // Update the currentEntities state to reflect the change - setCurrentEntities(prev => - prev.map(entity => + setCurrentEntities(prev => { + const updated = prev.map(entity => entity.SchemaName === entitySchemaName - ? { ...entity, Attributes: [...entity.Attributes, attribute] } + ? { + ...entity, + Attributes: attributeExists ? entity.Attributes : [...entity.Attributes, attribute], + manuallyAddedAttributes: [ + ...(entity.manuallyAddedAttributes || []), + ...(entity.manuallyAddedAttributes?.includes(attribute.SchemaName) ? [] : [attribute.SchemaName]) + ] + } : entity - ) - ); - - console.log(`Added attribute ${attribute.DisplayName} to entity ${entitySchemaName}`); + ); + return updated; + }); } + + // Reset the flag + isAddingAttributeRef.current = false; }, []); const initializePaper = useCallback((container: HTMLElement, options: any = {}) => { diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts index a3987bc..479ccc8 100644 --- a/Website/lib/Types.ts +++ b/Website/lib/Types.ts @@ -26,6 +26,7 @@ export type EntityType = { SecurityRoles: SecurityRole[], Keys: Key[], IconBase64: string | null, + manuallyAddedAttributes?: string[], } export const enum RequiredLevel { diff --git a/Website/routes/DiagramView.tsx b/Website/routes/DiagramView.tsx index cb553e9..32adb56 100644 --- a/Website/routes/DiagramView.tsx +++ b/Website/routes/DiagramView.tsx @@ -115,10 +115,6 @@ const DiagramContent: React.FC = () => { const entityHeights = currentEntities.map(entity => calculateEntityHeight(entity)); const maxEntityHeight = Math.max(...entityHeights, layoutOptions.entityHeight); - console.log('Entity heights:', entityHeights); - console.log('Max entity height:', maxEntityHeight); - console.log('Container dimensions:', { width: actualContainerWidth, height: actualContainerHeight }); - // Use the maximum height for layout calculation to ensure proper spacing const adjustedLayoutOptions = { ...updatedLayoutOptions, @@ -126,8 +122,6 @@ const DiagramContent: React.FC = () => { }; const layout = calculateGridLayout(currentEntities, adjustedLayoutOptions); - - console.log('Layout result:', { columns: layout.columns, rows: layout.rows, positions: layout.positions.length }); // Store entity elements and port maps by SchemaName for easy lookup const entityMap = new Map(); @@ -260,7 +254,6 @@ const DiagramContent: React.FC = () => { useEffect(() => { if (!selectedKey || !graph) return; - console.log(graph.getLinks()) // const links = graph.getLinks().filter(link => link.target().id === selectedKey); }, [selectedKey, graph]); @@ -309,7 +302,7 @@ const DiagramContent: React.FC = () => { selectedGroup={selectedGroup} onGroupSelect={selectGroup} /> - + {/* Diagram Controls */} From e837122cfaa78bc9ee50ed864047eecb122723db Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 12 Jul 2025 10:06:20 +0200 Subject: [PATCH 09/45] chore: moved diagram sidebar to new navigation --- Website/app/diagram/page.tsx | 15 +- Website/components/SidebarNavRail.tsx | 4 +- Website/components/diagram/DiagramCanvas.tsx | 4 +- .../components/diagram/DiagramControls.tsx | 2 +- .../diagram}/DiagramView.tsx | 164 +++++++----------- Website/components/diagram/GroupSelector.tsx | 26 +-- .../components/diagram/SidebarDiagramView.tsx | 57 ++++++ Website/contexts/DiagramContext.tsx | 28 --- Website/contexts/DiagramViewContext.tsx | 28 +++ Website/package-lock.json | 23 --- 10 files changed, 180 insertions(+), 171 deletions(-) rename Website/{routes => components/diagram}/DiagramView.tsx (73%) create mode 100644 Website/components/diagram/SidebarDiagramView.tsx delete mode 100644 Website/contexts/DiagramContext.tsx create mode 100644 Website/contexts/DiagramViewContext.tsx diff --git a/Website/app/diagram/page.tsx b/Website/app/diagram/page.tsx index a280a19..a208404 100644 --- a/Website/app/diagram/page.tsx +++ b/Website/app/diagram/page.tsx @@ -1,3 +1,14 @@ "use client"; -import DiagramView from "@/routes/DiagramView"; -export default DiagramView; \ No newline at end of file + +import { TouchProvider } from "@/components/ui/hybridtooltop"; +import { Loading } from "@/components/ui/loading"; +import DiagramView from "@/components/diagram/DiagramView"; +import { Suspense } from "react"; + +export default function Home() { + return }> + + + + +} \ No newline at end of file diff --git a/Website/components/SidebarNavRail.tsx b/Website/components/SidebarNavRail.tsx index ebedd96..db9a181 100644 --- a/Website/components/SidebarNavRail.tsx +++ b/Website/components/SidebarNavRail.tsx @@ -14,9 +14,9 @@ const navItems = [ { label: "Diagram Viewer", icon: , - href: "#", + href: "/diagram", active: false, - disabled: true, + disabled: false, }, ]; diff --git a/Website/components/diagram/DiagramCanvas.tsx b/Website/components/diagram/DiagramCanvas.tsx index ccea279..0af00da 100644 --- a/Website/components/diagram/DiagramCanvas.tsx +++ b/Website/components/diagram/DiagramCanvas.tsx @@ -1,5 +1,5 @@ +import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; import React, { useRef, useEffect, useState } from 'react'; -import { useDiagramContext } from '@/contexts/DiagramContext'; interface DiagramCanvasProps { children?: React.ReactNode; @@ -12,7 +12,7 @@ export const DiagramCanvas: React.FC = ({ children }) => { isPanning, initializePaper, destroyPaper - } = useDiagramContext(); + } = useDiagramViewContext(); useEffect(() => { if (canvasRef.current) { diff --git a/Website/components/diagram/DiagramControls.tsx b/Website/components/diagram/DiagramControls.tsx index 30a9ce9..2d1fbb9 100644 --- a/Website/components/diagram/DiagramControls.tsx +++ b/Website/components/diagram/DiagramControls.tsx @@ -10,7 +10,7 @@ import { Layers, Search } from 'lucide-react'; -import { useDiagramContext } from '@/contexts/DiagramContext'; +import { useDiagramContext } from '@/contexts/DiagramViewContext'; export const DiagramControls: React.FC = () => { const { diff --git a/Website/routes/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx similarity index 73% rename from Website/routes/DiagramView.tsx rename to Website/components/diagram/DiagramView.tsx index 32adb56..7560cbc 100644 --- a/Website/routes/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -2,20 +2,12 @@ import React, { useEffect, useState } from 'react' import { dia, shapes, util } from '@joint/core' -import { Groups } from "../generated/Data" +import { Groups } from "../../generated/Data" import { EntityElement } from '@/components/diagram/entity/entity'; import debounce from 'lodash/debounce'; -import { - SidebarProvider, - Sidebar, - SidebarHeader, - SidebarContent, - SidebarFooter -} from '@/components/ui/sidebar'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { PanelLeft, ZoomIn, ZoomOut } from 'lucide-react'; -import { DiagramProvider, useDiagramContext } from '@/contexts/DiagramContext'; import { DiagramCanvas } from '@/components/diagram/DiagramCanvas'; import { GroupSelector } from '@/components/diagram/GroupSelector'; import { DiagramResetButton } from '@/components/diagram/DiagramResetButton'; @@ -24,6 +16,11 @@ import { AddAttributeModal } from '@/components/diagram/AddAttributeModal'; import { calculateGridLayout, getDefaultLayoutOptions, calculateEntityHeight } from '@/components/diagram/GridLayoutManager'; import { AttributeType } from '@/lib/Types'; import { createBackgroundDots } from '@/components/diagram/BackgroundDots'; +import { TooltipProvider } from '@radix-ui/react-tooltip'; +import { AppSidebar } from '../AppSidebar'; +import { DiagramViewProvider, useDiagramViewContext } from '@/contexts/DiagramViewContext'; +import { SidebarDiagramView } from './SidebarDiagramView'; +import { useSidebarDispatch } from '@/contexts/SidebarContext'; interface IDiagramView {} @@ -46,7 +43,7 @@ const DiagramContent: React.FC = () => { resetView, fitToScreen, addAttributeToEntity - } = useDiagramContext(); + } = useDiagramViewContext(); const [selectedKey, setSelectedKey] = useState(); const [isSidebarOpen, setIsSidebarOpen] = useState(true); @@ -288,97 +285,48 @@ const DiagramContent: React.FC = () => { EntityElement.getVisibleItemsAndPorts(selectedEntity).visibleItems : []; return ( - -
- {/* Left Sidebar */} - - -

Diagram Tools

-
- - {/* Group Selection */} - - - - - {/* Diagram Controls */} -
-

Controls

-
- - -
- -
-
- -
- Zoom: {Math.round(zoom * 100)}% -
-
-
- - {/* Main Content */} -
- {/* Top Toolbar */} -
-
-
-

Data Model Diagram

- -
- Group: - - {selectedGroup?.Name || 'None'} - -
- -
- Entities: - - {currentEntities.length} - -
+ <> +
+ {/* Top Toolbar */} +
+
+
+

Data Model Diagram

+ +
+ Group: + + {selectedGroup?.Name || 'None'} +
+
- + Entities: + + {currentEntities.length} +
+
+ +
- - {/* Diagram Area */} - - {/* Zoom and Coordinate Indicator */} - -
+ + {/* Diagram Area */} + + {/* Zoom and Coordinate Indicator */} + +
{/* Add Attribute Modal */} @@ -390,14 +338,30 @@ const DiagramContent: React.FC = () => { availableAttributes={availableAttributes} visibleAttributes={visibleAttributes} /> - - ); + + ) + }; export default function DiagramView({ }: IDiagramView) { + const dispatch = useSidebarDispatch(); + + useEffect(() => { + dispatch({ type: "SET_ELEMENT", payload: }) + }, []) + return ( - - - + +
+ +
+
+ + + +
+
+
+
); } diff --git a/Website/components/diagram/GroupSelector.tsx b/Website/components/diagram/GroupSelector.tsx index 69b9603..7ba4ba5 100644 --- a/Website/components/diagram/GroupSelector.tsx +++ b/Website/components/diagram/GroupSelector.tsx @@ -17,18 +17,18 @@ export const GroupSelector: React.FC = ({ onGroupSelect }) => { return ( -
-
-

Groups

- +
+
+

Groups

+ {groups.length} total
-
-
+
+
{groups.map((group) => { const isSelected = selectedGroup?.Name === group.Name; const entityCount = group.Entities.length; @@ -38,29 +38,29 @@ export const GroupSelector: React.FC = ({ key={group.Name} variant={isSelected ? "secondary" : "ghost"} className={cn( - "w-full justify-start h-auto p-3", + "w-full justify-start h-auto p-3 min-w-0", isSelected && "bg-secondary" )} onClick={() => onGroupSelect(group)} > -
+
{isSelected ? ( - + ) : ( - + )} -
+
{group.Name}
-
+
{entityCount} {entityCount === 1 ? 'entity' : 'entities'}
{isSelected && ( -
+
)}
diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx new file mode 100644 index 0000000..d2d4dff --- /dev/null +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -0,0 +1,57 @@ +import { Groups } from "@/generated/Data" +import { Separator } from "@radix-ui/react-select" +import { GroupSelector } from "./GroupSelector" +import { useDiagramViewContext } from "@/contexts/DiagramViewContext"; +import { ZoomOut, ZoomIn } from "lucide-react"; +import { Button } from "../ui/button"; +import { DiagramResetButton } from "./DiagramResetButton"; + +interface ISidebarDiagramViewProps { + +} + +export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { + + const { selectedGroup, selectGroup, zoomOut, zoomIn, zoom, resetView } = useDiagramViewContext(); + + return ( +
+
+ +
+ + + +
+

Controls

+
+ + +
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/Website/contexts/DiagramContext.tsx b/Website/contexts/DiagramContext.tsx deleted file mode 100644 index 8f5eb50..0000000 --- a/Website/contexts/DiagramContext.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { createContext, useContext, ReactNode } from 'react'; -import { useDiagram, DiagramState, DiagramActions } from '@/hooks/useDiagram'; - -interface DiagramContextType extends DiagramState, DiagramActions {} - -const DiagramContext = createContext(null); - -interface DiagramProviderProps { - children: ReactNode; -} - -export const DiagramProvider: React.FC = ({ children }) => { - const diagramState = useDiagram(); - - return ( - - {children} - - ); -}; - -export const useDiagramContext = (): DiagramContextType => { - const context = useContext(DiagramContext); - if (!context) { - throw new Error('useDiagramContext must be used within a DiagramProvider'); - } - return context; -}; \ No newline at end of file diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx new file mode 100644 index 0000000..c79e6d3 --- /dev/null +++ b/Website/contexts/DiagramViewContext.tsx @@ -0,0 +1,28 @@ +import React, { createContext, useContext, ReactNode } from 'react'; +import { useDiagram, DiagramState, DiagramActions } from '@/hooks/useDiagram'; + +interface DiagramViewContextType extends DiagramState, DiagramActions {} + +const DiagramViewContext = createContext(null); + +interface DiagramViewProviderProps { + children: ReactNode; +} + +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; +}; \ No newline at end of file diff --git a/Website/package-lock.json b/Website/package-lock.json index 97ce8ad..0c1c79b 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -2826,29 +2826,6 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", - "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", From 5ddd93672bd3c19277e4bc913e0ebff4773432cf Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 12 Jul 2025 13:03:23 +0200 Subject: [PATCH 10/45] chore: canvas now able to render again --- Website/components/diagram/DiagramCanvas.tsx | 5 +++-- .../components/diagram/DiagramControls.tsx | 8 +++---- Website/components/diagram/DiagramView.tsx | 21 +++++++------------ Website/components/diagram/entity/entity.ts | 4 ++-- Website/hooks/useDiagram.ts | 2 +- 5 files changed, 18 insertions(+), 22 deletions(-) diff --git a/Website/components/diagram/DiagramCanvas.tsx b/Website/components/diagram/DiagramCanvas.tsx index 0af00da..64990e1 100644 --- a/Website/components/diagram/DiagramCanvas.tsx +++ b/Website/components/diagram/DiagramCanvas.tsx @@ -8,6 +8,7 @@ interface DiagramCanvasProps { export const DiagramCanvas: React.FC = ({ children }) => { const canvasRef = useRef(null); const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); + const { isPanning, initializePaper, @@ -16,7 +17,7 @@ export const DiagramCanvas: React.FC = ({ children }) => { useEffect(() => { if (canvasRef.current) { - const paper = initializePaper(canvasRef.current, { + initializePaper(canvasRef.current, { background: { color: 'transparent' // Make paper background transparent to show CSS dots } @@ -41,7 +42,7 @@ export const DiagramCanvas: React.FC = ({ children }) => { }, [initializePaper, destroyPaper]); return ( -
+
{ const { zoom, resetView, fitToScreen - } = useDiagramContext(); + } = useDiagramViewContext(); return (
@@ -81,7 +81,7 @@ export const DiagramControls: React.FC = () => { }; export const DiagramZoomDisplay: React.FC = () => { - const { zoom } = useDiagramContext(); + const { zoom } = useDiagramViewContext(); return (
@@ -91,7 +91,7 @@ export const DiagramZoomDisplay: React.FC = () => { }; export const DiagramZoomControls: React.FC = () => { - const { zoomIn, zoomOut } = useDiagramContext(); + const { zoomIn, zoomOut } = useDiagramViewContext(); return (
diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 7560cbc..1abec7c 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -29,7 +29,7 @@ const routerPadding = 16; const routerTries = 5000; const routerDirections = 90; -const DiagramContent: React.FC = () => { +const DiagramContent = () => { const { graph, paper, @@ -184,23 +184,23 @@ const DiagramContent: React.FC = () => { connector: { name: 'rounded' }, attrs: { line: { - stroke: '#6366f1', + stroke: '#42a5f5', strokeWidth: 2, sourceMarker: { type: 'ellipse', - cx: 0, + cx: -6, cy: 0, rx: 4, ry: 4, fill: '#fff', - stroke: '#6366f1', - strokeWidth: 2 + stroke: '#42a5f5', + strokeWidth: 2, }, targetMarker: { type: 'path', d: 'M 10 -5 L 0 0 L 10 5 Z', - fill: '#6366f1', - stroke: '#6366f1' + fill: '#42a5f5', + stroke: '#42a5f5' } } } @@ -340,7 +340,6 @@ const DiagramContent: React.FC = () => { /> ) - }; export default function DiagramView({ }: IDiagramView) { @@ -355,11 +354,7 @@ export default function DiagramView({ }: IDiagramView) {
-
- - - -
+
diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index db4bc09..1bef3d9 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -103,7 +103,7 @@ export class EntityElement extends dia.Element { groups: { left: { position: { - name: 'left' + name: 'left', }, attrs: { circle: { @@ -117,7 +117,7 @@ export class EntityElement extends dia.Element { }, right: { position: { - name: 'right' + name: 'right', }, attrs: { circle: { diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 35022df..9275eb3 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -257,7 +257,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { // Re-add background dots when paper size changes (e.g., on resize) paper.on('resize', () => createBackgroundDots(graphRef.current!, paper)); - + // Setup event listeners paper.on('blank:pointerdown', () => { updatePanningDisplay(true); From 456d1e7b6662ed350f6ea1e4c119921e38c073d2 Mon Sep 17 00:00:00 2001 From: boer Date: Sat, 12 Jul 2025 13:47:34 +0200 Subject: [PATCH 11/45] chore: click highlight keys --- Website/components/diagram/DiagramView.tsx | 142 ++++++++++++++---- .../diagram/entity/EntityAttribute.ts | 9 +- .../components/diagram/entity/EntityBody.ts | 9 +- Website/components/diagram/entity/entity.ts | 3 +- 4 files changed, 132 insertions(+), 31 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 1abec7c..244a4ec 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -58,7 +58,7 @@ const DiagramContent = () => { }, [Groups, selectedGroup, selectGroup]); useEffect(() => { - document.addEventListener('click', (e) => { + const handleDocumentClick = (e: MouseEvent) => { const target = (e.target as HTMLElement).closest('button[data-schema-name]') as HTMLElement; const addButton = (e.target as HTMLElement).closest('button[data-add-attribute]') as HTMLElement; @@ -73,15 +73,24 @@ const DiagramContent = () => { return; } - if (!target) return; + if (target) { + const schemaName = target.dataset.schemaName!; + const isKey = target.dataset.isKey === 'true'; - const schemaName = target.dataset.schemaName!; - const isKey = target.dataset.isKey === 'true'; - - if (isKey) { - setSelectedKey(schemaName); + if (isKey) { + setSelectedKey(schemaName); + } + } else { + // Clicked outside of any key attribute, clear selection + setSelectedKey(undefined); } - }); + }; + + document.addEventListener('click', handleDocumentClick); + + return () => { + document.removeEventListener('click', handleDocumentClick); + }; }, []); useEffect(() => { @@ -129,17 +138,26 @@ const DiagramContent = () => { const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); const entityElement = new EntityElement({ position, - data: { entity, setSelectedKey } + data: { entity, setSelectedKey, selectedKey } }); entityElement.addTo(graph); entityMap.set(entity.SchemaName, { element: entityElement, portMap }); }); util.nextFrame(() => { - const allElements = graph.getElements(); - // Filter out background dots from obstacles - only use actual entity elements - const entityElements = allElements.filter(el => el.get('type') !== 'background.dots'); - const obstacles = entityElements.map(el => el.getBBox().inflate(routerPadding)); + // Get all entity elements (exclude background dots and links) + const entityElements = graph.getElements().filter(el => + el.get('type') !== 'background.dots' && + el.get('type') !== 'standard.Link' + ); + + // Create obstacles from entity bounding boxes with padding + const obstacles = entityElements.map(el => { + const bbox = el.getBBox(); + return bbox.inflate(routerPadding); // This returns a Rect, not an object with bbox() + }); + + console.log('Created obstacles:', obstacles.length, 'for entities:', entityElements.length); // Create relationships for (const entity of currentEntities) { @@ -172,11 +190,15 @@ const DiagramContent = () => { args: { startDirections: ['left', 'right'], endDirections: ['left', 'right'], - step: paper.options.gridSize, + step: paper.options.gridSize || 8, padding: routerPadding, maximumLoops: routerTries, - isPointObstacle: (p: dia.Point) => { - return obstacles.some(obs => obs.bbox().containsPoint(p)) + // Fixed obstacle detection function + isPointObstacle: (point: dia.Point) => { + return obstacles.some(obstacle => { + // obstacle is already a Rect from inflate(), so we can use containsPoint directly + return obstacle.containsPoint(point); + }); }, maxAllowedDirectionChange: routerDirections } @@ -204,7 +226,6 @@ const DiagramContent = () => { } } } - // No labels }); link.addTo(graph); @@ -213,25 +234,37 @@ const DiagramContent = () => { } }); + // Fixed rerouting function const reroute = debounce(() => { - const elements = graph.getElements().filter(el => el.get('type') !== 'standard.Link'); - // Filter out background dots from obstacles - only use actual entity elements - const entityElements = elements.filter(el => el.get('type') !== 'background.dots'); + console.log('Rerouting links...'); + + // Get all entity elements (exclude background dots and links) + const entityElements = graph.getElements().filter(el => + el.get('type') !== 'background.dots' && + el.get('type') !== 'standard.Link' + ); + + // Create fresh obstacles for rerouting const obstacles = entityElements.map(el => { const bbox = el.getBBox(); - const inflated = bbox.inflate(routerPadding); - return inflated; + return bbox.inflate(routerPadding); }); + console.log('Rerouting with obstacles:', obstacles.length); + + // Update all links with new routing graph.getLinks().forEach(link => { link.router(routerType, { startDirections: ['left', 'right'], endDirections: ['left', 'right'], - step: paper.options.gridSize, + step: paper.options.gridSize || 8, padding: routerPadding, maximumLoops: routerTries, - isPointObstacle: (p: dia.Point) => { - return obstacles.some(obs => obs.bbox().containsPoint(p)) + // Fixed obstacle detection for rerouting + isPointObstacle: (point: dia.Point) => { + return obstacles.some(obstacle => { + return obstacle.containsPoint(point); + }); }, maxAllowedDirectionChange: routerDirections }); @@ -251,7 +284,64 @@ const DiagramContent = () => { useEffect(() => { if (!selectedKey || !graph) return; - // const links = graph.getLinks().filter(link => link.target().id === selectedKey); + + // Reset all links to default styling + graph.getLinks().forEach(link => { + link.attr('line/stroke', '#42a5f5'); + link.attr('line/strokeWidth', 2); + }); + + // Find the entity that contains the selected key + const entityWithKey = currentEntities.find(entity => + entity.Attributes.some(attr => + attr.SchemaName === selectedKey && attr.IsPrimaryId + ) + ); + + if (entityWithKey) { + // Find all links that target this entity's key port + const targetEntityId = graph.getElements().find(el => + el.get('type') === 'delegate.entity' && + el.get('data')?.entity?.SchemaName === entityWithKey.SchemaName + )?.id; + + if (targetEntityId) { + const targetPort = `port-${selectedKey.toLowerCase()}`; + + // Highlight links that target this specific key port + const linksToHighlight = graph.getLinks().filter(link => { + const target = link.target(); + return target.id === targetEntityId && target.port === targetPort; + }); + + // Apply highlighting to the found links + linksToHighlight.forEach(link => { + link.attr('line/stroke', '#ff6b6b'); // Red color for highlighted links + link.attr('line/strokeWidth', 4); // Thicker stroke for highlighted links + }); + + console.log(`Highlighted ${linksToHighlight.length} links targeting key: ${selectedKey}`); + } + } + }, [selectedKey, graph, currentEntities]); + + // Update entity elements when selectedKey changes + useEffect(() => { + if (!graph || !selectedKey) return; + + // Update all entity elements with the new selectedKey + graph.getElements().forEach(el => { + if (el.get('type') === 'delegate.entity') { + const currentData = el.get('data'); + el.set('data', { ...currentData, selectedKey }); + + // Trigger re-render of the entity + const entityElement = el as EntityElement; + if (entityElement.updateAttributes) { + entityElement.updateAttributes(currentData.entity); + } + } + }); }, [selectedKey, graph]); useEffect(() => { diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/entity/EntityAttribute.ts index 1cb69fa..94c5f60 100644 --- a/Website/components/diagram/entity/EntityAttribute.ts +++ b/Website/components/diagram/entity/EntityAttribute.ts @@ -3,9 +3,10 @@ import { AttributeType } from "@/lib/Types"; interface IEntityAttribute { attribute: AttributeType; isKey: boolean; + isSelected?: boolean; } -export const EntityAttribute = ({ attribute, isKey, isAddButton = false }: IEntityAttribute & { isAddButton?: boolean }): string => { +export const EntityAttribute = ({ attribute, isKey, isSelected = false, isAddButton = false }: IEntityAttribute & { isAddButton?: boolean }): string => { if (isAddButton) { return ` diff --git a/Website/components/diagram/entity/EntityBody.ts b/Website/components/diagram/entity/EntityBody.ts index 397dac4..5676f6b 100644 --- a/Website/components/diagram/entity/EntityBody.ts +++ b/Website/components/diagram/entity/EntityBody.ts @@ -4,9 +4,10 @@ import { EntityAttribute } from './EntityAttribute'; interface IEntityBody { entity: EntityType; visibleItems: AttributeType[]; + selectedKey?: string; } -export function EntityBody({ entity, visibleItems }: IEntityBody): string { +export function EntityBody({ entity, visibleItems, selectedKey }: IEntityBody): string { const icon = entity.IconBase64 != null ? `data:image/svg+xml;base64,${entity.IconBase64}` @@ -27,7 +28,11 @@ export function EntityBody({ entity, visibleItems }: IEntityBody): string {
- ${visibleItems.map((attribute, i) => (EntityAttribute({ attribute, isKey: i == 0 }))).join('')} + ${visibleItems.map((attribute, i) => (EntityAttribute({ + attribute, + isKey: i == 0, + isSelected: selectedKey === attribute.SchemaName + }))).join('')} ${EntityAttribute({ attribute: { DisplayName: '', SchemaName: '', AttributeType: 'GenericAttribute' } as any, isKey: false, isAddButton: true })}
diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index 1bef3d9..e138b4b 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -46,7 +46,8 @@ export class EntityElement extends dia.Element { updateAttributes(entity: EntityType) { const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); - const html = EntityBody({ entity, visibleItems }); + const selectedKey = (this.get('data') as any)?.selectedKey; + const html = EntityBody({ entity, visibleItems, selectedKey }); // Markup const baseMarkup = [ From daea0e206bb21f40d921ddfa8ab614a8bf0df1ba Mon Sep 17 00:00:00 2001 From: boer Date: Sun, 13 Jul 2025 13:08:50 +0200 Subject: [PATCH 12/45] chore: redid creation of background dots --- Website/components/diagram/BackgroundDots.ts | 38 ---- Website/components/diagram/DiagramCanvas.tsx | 2 +- Website/components/diagram/DiagramView.tsx | 188 ++++++++++++++---- .../components/diagram/SidebarDiagramView.tsx | 30 ++- .../diagram/entity/SimpleEntityElement.ts | 90 +++++++++ Website/hooks/useDiagram.ts | 18 +- 6 files changed, 275 insertions(+), 91 deletions(-) delete mode 100644 Website/components/diagram/BackgroundDots.ts create mode 100644 Website/components/diagram/entity/SimpleEntityElement.ts diff --git a/Website/components/diagram/BackgroundDots.ts b/Website/components/diagram/BackgroundDots.ts deleted file mode 100644 index 69c2eec..0000000 --- a/Website/components/diagram/BackgroundDots.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { dia } from '@joint/core'; - -export const createBackgroundDots = (graph: dia.Graph, paper: dia.Paper) => { - // Remove existing background dots - graph.getElements().forEach(el => { - if (el.get('type') === 'background.dots') { - el.remove(); - } - }); - - const paperSize = paper.getComputedSize(); - const dotSpacing = 20; // Base spacing between dots - const dotRadius = 1; // Smaller, less prominent dots - - // Create dots across the entire paper area - for (let x = 0; x < paperSize.width; x += dotSpacing) { - for (let y = 0; y < paperSize.height; y += dotSpacing) { - const dot = new dia.Element({ - type: 'background.dots', - position: { x, y }, - size: { width: dotRadius * 2, height: dotRadius * 2 }, - attrs: { - body: { - cx: dotRadius, - cy: dotRadius, - r: dotRadius, - fill: '#d1d5db', // Light gray, less prominent - stroke: 'none' - } - }, - markup: [ - { tagName: 'circle', selector: 'body' } - ] - }); - dot.addTo(graph); - } - } -}; \ No newline at end of file diff --git a/Website/components/diagram/DiagramCanvas.tsx b/Website/components/diagram/DiagramCanvas.tsx index 64990e1..b1b4d48 100644 --- a/Website/components/diagram/DiagramCanvas.tsx +++ b/Website/components/diagram/DiagramCanvas.tsx @@ -42,7 +42,7 @@ export const DiagramCanvas: React.FC = ({ children }) => { }, [initializePaper, destroyPaper]); return ( -
+
{ zoomOut, resetView, fitToScreen, - addAttributeToEntity + addAttributeToEntity, + diagramType } = useDiagramViewContext(); const [selectedKey, setSelectedKey] = useState(); @@ -99,9 +100,6 @@ const DiagramContent = () => { // Clear existing elements graph.clear(); - // Recreate background dots after clearing - createBackgroundDots(graph, paper); - // Calculate grid layout const layoutOptions = getDefaultLayoutOptions(); @@ -135,13 +133,25 @@ const DiagramContent = () => { // Create entities in grid layout currentEntities.forEach((entity, index) => { const position = layout.positions[index] || { x: 50, y: 50 }; - const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); - const entityElement = new EntityElement({ - position, - data: { entity, setSelectedKey, selectedKey } - }); - entityElement.addTo(graph); - entityMap.set(entity.SchemaName, { element: entityElement, portMap }); + + if (diagramType === 'simple') { + // Create simple entity (no attributes, no ports) + const entityElement = new SimpleEntityElement({ + position, + data: { entity } + }); + entityElement.addTo(graph); + entityMap.set(entity.SchemaName, { element: entityElement, portMap: {} }); + } else { + // Create detailed entity with attributes and ports + const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); + const entityElement = new EntityElement({ + position, + data: { entity, setSelectedKey, selectedKey } + }); + entityElement.addTo(graph); + entityMap.set(entity.SchemaName, { element: entityElement, portMap }); + } }); util.nextFrame(() => { @@ -166,20 +176,86 @@ const DiagramContent = () => { const { portMap } = entityInfo; const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); - for (let i = 1; i < visibleItems.length; i++) { - const attr = visibleItems[i]; - if (attr.AttributeType !== "LookupAttribute") continue; + if (diagramType === 'simple') { + // For simple diagram, create links between entity centers + for (const entity of currentEntities) { + const entityInfo = entityMap.get(entity.SchemaName); + if (!entityInfo) continue; + + // Find all lookup attributes and create links + for (const attr of entity.Attributes) { + if (attr.AttributeType !== "LookupAttribute") continue; + + for (const target of attr.Targets) { + const targetInfo = entityMap.get(target.Name); + if (!targetInfo) continue; + + const sourceId = entityInfo.element.id; + const targetId = targetInfo.element.id; + + // Create link connecting entity centers + const link = new shapes.standard.Link({ + source: { id: sourceId }, + target: { id: targetId }, + router: { + name: routerType, + args: { + startDirections: ['left', 'right', 'top', 'bottom'], + endDirections: ['left', 'right', 'top', 'bottom'], + step: paper.options.gridSize, + padding: routerPadding, + maximumLoops: routerTries, + isPointObstacle: (p: dia.Point) => { + return obstacles.some(obs => obs.containsPoint(p)) + }, + maxAllowedDirectionChange: routerDirections + } + }, + connector: { name: 'rounded' }, + 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 10 -5 L 0 0 L 10 5 Z', + fill: '#42a5f5', + stroke: '#42a5f5' + } + } + } + }); + + link.addTo(graph); + } + } + } + } else { + // For detailed diagram, use ports + 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; + 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`]; - const sourceId = entityInfo.element.id; - const targetId = targetInfo.element.id; + const sourcePort = portMap[attr.SchemaName.toLowerCase()]; + const targetPort = targetInfo.portMap[`${target.Name.toLowerCase()}id`]; + const sourceId = entityInfo.element.id; + const targetId = targetInfo.element.id; - if (!sourcePort || !targetPort) continue; + if (!sourcePort || !targetPort) continue; // Use a filled arrow for 'many' side, and a small circle for 'one' side const link = new shapes.standard.Link({ @@ -232,7 +308,8 @@ const DiagramContent = () => { } } } - }); + } + }); // Fixed rerouting function const reroute = debounce(() => { @@ -280,7 +357,7 @@ const DiagramContent = () => { return () => { graph.off('change:position change:size change:attrs.line', reroute); }; - }, [graph, paper, selectedGroup, currentEntities]); + }, [graph, paper, selectedGroup, currentEntities, diagramType]); useEffect(() => { if (!selectedKey || !graph) return; @@ -289,6 +366,8 @@ const DiagramContent = () => { 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'); }); // Find the entity that contains the selected key @@ -299,16 +378,31 @@ const DiagramContent = () => { ); if (entityWithKey) { - // Find all links that target this entity's key port - const targetEntityId = graph.getElements().find(el => - el.get('type') === 'delegate.entity' && - el.get('data')?.entity?.SchemaName === entityWithKey.SchemaName - )?.id; - - if (targetEntityId) { + // Find all links that target this entity's key port + const targetEntityId = graph.getElements().find(el => + (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') && + el.get('data')?.entity?.SchemaName === entityWithKey.SchemaName + )?.id; + + if (targetEntityId) { + if (diagramType === 'simple') { + // For simple diagram, highlight all links to this entity + const linksToHighlight = graph.getLinks().filter(link => { + const target = link.target(); + return target.id === targetEntityId; + }); + + // Apply highlighting to the found links + linksToHighlight.forEach(link => { + link.attr('line/stroke', '#ff6b6b'); // Red color for highlighted links + link.attr('line/strokeWidth', 4); // Thicker stroke for highlighted links + }); + + console.log(`Highlighted ${linksToHighlight.length} links targeting entity: ${entityWithKey.SchemaName}`); + } else { + // For detailed diagram, highlight links to specific key port const targetPort = `port-${selectedKey.toLowerCase()}`; - // Highlight links that target this specific key port const linksToHighlight = graph.getLinks().filter(link => { const target = link.target(); return target.id === targetEntityId && target.port === targetPort; @@ -323,6 +417,7 @@ const DiagramContent = () => { console.log(`Highlighted ${linksToHighlight.length} links targeting key: ${selectedKey}`); } } + } }, [selectedKey, graph, currentEntities]); // Update entity elements when selectedKey changes @@ -340,6 +435,9 @@ const DiagramContent = () => { if (entityElement.updateAttributes) { entityElement.updateAttributes(currentData.entity); } + } else if (el.get('type') === 'delegate.simple-entity') { + // Simple entities don't need key highlighting updates + // They don't have attributes to highlight } }); }, [selectedKey, graph]); @@ -410,13 +508,19 @@ const DiagramContent = () => {
{/* Diagram Area */} - - {/* Zoom and Coordinate Indicator */} - - + +
+ + {/* Zoom and Coordinate Indicator */} + + +
{/* Add Attribute Modal */} @@ -443,9 +547,7 @@ export default function DiagramView({ }: IDiagramView) {
-
- -
+
); diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx index d2d4dff..7ae20c7 100644 --- a/Website/components/diagram/SidebarDiagramView.tsx +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -2,9 +2,10 @@ import { Groups } from "@/generated/Data" import { Separator } from "@radix-ui/react-select" import { GroupSelector } from "./GroupSelector" import { useDiagramViewContext } from "@/contexts/DiagramViewContext"; -import { ZoomOut, ZoomIn } from "lucide-react"; +import { ZoomOut, ZoomIn, Layers, Database } from "lucide-react"; import { Button } from "../ui/button"; import { DiagramResetButton } from "./DiagramResetButton"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; interface ISidebarDiagramViewProps { @@ -12,7 +13,7 @@ interface ISidebarDiagramViewProps { export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { - const { selectedGroup, selectGroup, zoomOut, zoomIn, zoom, resetView } = useDiagramViewContext(); + const { selectedGroup, selectGroup, zoomOut, zoomIn, zoom, resetView, diagramType, updateDiagramType } = useDiagramViewContext(); return (
@@ -24,6 +25,31 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { />
+ + +
+

Diagram Type

+ +
+
diff --git a/Website/components/diagram/entity/SimpleEntityElement.ts b/Website/components/diagram/entity/SimpleEntityElement.ts new file mode 100644 index 0000000..ec299eb --- /dev/null +++ b/Website/components/diagram/entity/SimpleEntityElement.ts @@ -0,0 +1,90 @@ +import { EntityType } from '@/lib/Types'; +import { dia } from '@joint/core'; + +interface ISimpleEntityElement { + entity: EntityType; +} + +export class SimpleEntityElement extends dia.Element { + + initialize(...args: any[]) { + super.initialize(...args); + const { entity } = this.get('data') as ISimpleEntityElement; + if (entity) this.updateEntity(entity); + } + + 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: '#fff', + stroke: '#d1d5db', + rx: 12 + }, + fo: { + refWidth: '100%', + refHeight: '100%', + html + } + }); + + this.resize(width, height); + } + + private createSimpleEntityHTML(entity: EntityType): string { + const icon = entity.IconBase64 != null + ? `data:image/svg+xml;base64,${entity.IconBase64}` + : '/vercel.svg'; + + return ` +
+
+
+ +
+
+

${entity.DisplayName}

+

${entity.SchemaName}

+
+
+
+ `; + } + + defaults() { + return { + type: 'delegate.simple-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/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 9275eb3..0b1d265 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -1,9 +1,10 @@ import { useRef, useState, useCallback, useEffect } from 'react'; import { dia } from '@joint/core'; import { GroupType, EntityType, AttributeType } from '@/lib/Types'; -import { createBackgroundDots } from '@/components/diagram/BackgroundDots'; import { EntityElement } from '@/components/diagram/entity/entity'; +export type DiagramType = 'detailed' | 'simple'; + export interface DiagramState { zoom: number; isPanning: boolean; @@ -14,6 +15,7 @@ export interface DiagramState { currentEntities: EntityType[]; mousePosition: { x: number; y: number } | null; panPosition: { x: number; y: number }; + diagramType: DiagramType; } export interface DiagramActions { @@ -31,6 +33,7 @@ export interface DiagramActions { updateMousePosition: (position: { x: number; y: number } | null) => void; updatePanPosition: (position: { x: number; y: number }) => void; addAttributeToEntity: (entitySchemaName: string, attribute: AttributeType) => void; + updateDiagramType: (type: DiagramType) => void; } export const useDiagram = (): DiagramState & DiagramActions => { @@ -48,6 +51,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { 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('detailed'); // Update state when refs change (for UI updates) const updateZoomDisplay = useCallback((newZoom: number) => { @@ -230,6 +234,10 @@ export const useDiagram = (): DiagramState & DiagramActions => { isAddingAttributeRef.current = false; }, []); + const updateDiagramType = useCallback((type: DiagramType) => { + setDiagramType(type); + }, []); + const initializePaper = useCallback((container: HTMLElement, options: any = {}) => { // Create graph if it doesn't exist if (!graphRef.current) { @@ -251,12 +259,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { }); paperRef.current = paper; - - // Add dotted background that scales with zoom - createBackgroundDots(graphRef.current!, paper); - - // Re-add background dots when paper size changes (e.g., on resize) - paper.on('resize', () => createBackgroundDots(graphRef.current!, paper)); // Setup event listeners paper.on('blank:pointerdown', () => { @@ -373,6 +375,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { currentEntities, mousePosition, panPosition, + diagramType, // Actions zoomIn, @@ -389,5 +392,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { updateMousePosition, updatePanPosition, addAttributeToEntity, + updateDiagramType, }; }; \ No newline at end of file From ed81c27119ce03b62bee58b82d472c3a0dc862f2 Mon Sep 17 00:00:00 2001 From: boer Date: Sun, 13 Jul 2025 13:15:04 +0200 Subject: [PATCH 13/45] chore. selected attribute color stylings --- Website/components/diagram/DiagramView.tsx | 4 ++++ Website/components/diagram/entity/EntityAttribute.ts | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index b93091c..74e1ce2 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -368,6 +368,7 @@ const DiagramContent = () => { 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 that contains the selected key @@ -412,6 +413,9 @@ const DiagramContent = () => { linksToHighlight.forEach(link => { link.attr('line/stroke', '#ff6b6b'); // Red color for highlighted links link.attr('line/strokeWidth', 4); // Thicker stroke for highlighted links + link.attr('line/targetMarker/stroke', '#ff6b6b'); + link.attr('line/targetMarker/fill', '#ff6b6b'); + link.attr('line/sourceMarker/stroke', '#ff6b6b'); }); console.log(`Highlighted ${linksToHighlight.length} links targeting key: ${selectedKey}`); diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/entity/EntityAttribute.ts index 94c5f60..495408c 100644 --- a/Website/components/diagram/entity/EntityAttribute.ts +++ b/Website/components/diagram/entity/EntityAttribute.ts @@ -38,12 +38,13 @@ export const EntityAttribute = ({ attribute, isKey, isSelected = false, isAddBut return ` `; }; \ No newline at end of file From df9344f37b3d809948234fa51d7ef60f1c38741c Mon Sep 17 00:00:00 2001 From: boer Date: Sun, 13 Jul 2025 20:52:54 +0200 Subject: [PATCH 14/45] chore: gridlayout changes more dynamic spacing --- .../components/diagram/GridLayoutManager.ts | 70 ++++++------------- 1 file changed, 23 insertions(+), 47 deletions(-) diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts index cacdfae..e3ba2df 100644 --- a/Website/components/diagram/GridLayoutManager.ts +++ b/Website/components/diagram/GridLayoutManager.ts @@ -45,8 +45,8 @@ export const calculateGridLayout = ( entities: EntityType[], options: GridLayoutOptions ): GridLayoutResult => { - const { containerWidth, containerHeight, entityWidth, entityHeight, padding, margin } = options; - + const { containerWidth, entityWidth, padding, margin } = options; + if (entities.length === 0) { return { positions: [], @@ -57,66 +57,42 @@ export const calculateGridLayout = ( }; } - // Calculate available space - const availableWidth = containerWidth - (margin * 2); - const availableHeight = containerHeight - (margin * 2); + // Determine how many columns can fit + const maxColumns = Math.max(1, Math.floor((containerWidth - margin * 2 + padding) / (entityWidth + padding))); + const columns = Math.min(maxColumns, entities.length); - // Calculate aspect ratio of available space - const aspectRatio = availableWidth / availableHeight; - - // Determine optimal number of columns based on aspect ratio and entity count - let columns: number; - if (aspectRatio > 1.5) { - // Wide screen - prefer more columns - columns = Math.ceil(Math.sqrt(entities.length * aspectRatio)); - } else if (aspectRatio < 0.8) { - // Tall screen - prefer fewer columns - columns = Math.ceil(Math.sqrt(entities.length / aspectRatio)); - } else { - // Square-ish screen - balanced approach - columns = Math.ceil(Math.sqrt(entities.length)); - } - - // Ensure we don't exceed available width - const maxColumnsByWidth = Math.floor(availableWidth / (entityWidth + padding)); - columns = Math.min(columns, maxColumnsByWidth); - - // Ensure we have at least 1 column - columns = Math.max(1, columns); - - // Calculate rows needed - const rows = Math.ceil(entities.length / columns); + // Initialize arrays to track column heights + const columnHeights: number[] = Array(columns).fill(0); + const positions: GridPosition[] = []; - // Calculate grid dimensions - const gridWidth = columns * entityWidth + (columns - 1) * padding; - const gridHeight = rows * entityHeight + (rows - 1) * padding; + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + const height = calculateEntityHeight(entity); - // Calculate starting position to center the grid - const startX = margin + (availableWidth - gridWidth) / 2; - const startY = margin + (availableHeight - gridHeight) / 2; + // Assign to the column with the least cumulative height + const colIndex = columnHeights.indexOf(Math.min(...columnHeights)); + const x = margin + colIndex * (entityWidth + padding); + const y = margin + columnHeights[colIndex]; - // Calculate positions for each entity - const positions: GridPosition[] = []; - - for (let i = 0; i < entities.length; i++) { - const row = Math.floor(i / columns); - const col = i % columns; - - const x = startX + col * (entityWidth + padding); - const y = startY + row * (entityHeight + padding); - positions.push({ x, y }); + + // Add height + padding to the selected column + columnHeights[colIndex] += height + padding; } + const gridWidth = columns * entityWidth + (columns - 1) * padding; + const gridHeight = Math.max(...columnHeights) - padding; // remove trailing padding + return { positions, gridWidth, gridHeight, columns, - rows + rows: Math.ceil(entities.length / columns) // estimated rows }; }; + /** * Estimates entity dimensions based on content */ From 88f60beee1d54d767ee6d4efbb41ca602e10f7f1 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Thu, 7 Aug 2025 17:19:13 +0200 Subject: [PATCH 15/45] chore: refactor avoid router code into TS and applying to newer JointJS --- .../datamodelview/TimeSlicedSearch.tsx | 0 .../components/datamodelview/searchWorker.js | 0 Website/components/diagram/DiagramView.tsx | 55 +-- .../diagram/avoid-router/avoidrouter.ts | 389 ++++++++++++++++++ Website/contexts/DatamodelDataContext.tsx | 0 Website/contexts/SearchPerformanceContext.tsx | 0 Website/hooks/useDiagram.ts | 33 +- Website/public/libavoid.wasm | Bin 0 -> 485460 bytes 8 files changed, 426 insertions(+), 51 deletions(-) create mode 100644 Website/components/datamodelview/TimeSlicedSearch.tsx create mode 100644 Website/components/datamodelview/searchWorker.js create mode 100644 Website/components/diagram/avoid-router/avoidrouter.ts create mode 100644 Website/contexts/DatamodelDataContext.tsx create mode 100644 Website/contexts/SearchPerformanceContext.tsx create mode 100644 Website/public/libavoid.wasm diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx new file mode 100644 index 0000000..e69de29 diff --git a/Website/components/datamodelview/searchWorker.js b/Website/components/datamodelview/searchWorker.js new file mode 100644 index 0000000..e69de29 diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 74e1ce2..6895903 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -24,10 +24,10 @@ import { useSidebarDispatch } from '@/contexts/SidebarContext'; interface IDiagramView {} -const routerType = "manhattan" +// const routerType = "manhattan" const routerPadding = 16; -const routerTries = 5000; -const routerDirections = 90; +// const routerTries = 5000; +// const routerDirections = 90; const DiagramContent = () => { const { @@ -197,20 +197,7 @@ const DiagramContent = () => { const link = new shapes.standard.Link({ source: { id: sourceId }, target: { id: targetId }, - router: { - name: routerType, - args: { - startDirections: ['left', 'right', 'top', 'bottom'], - endDirections: ['left', 'right', 'top', 'bottom'], - step: paper.options.gridSize, - padding: routerPadding, - maximumLoops: routerTries, - isPointObstacle: (p: dia.Point) => { - return obstacles.some(obs => obs.containsPoint(p)) - }, - maxAllowedDirectionChange: routerDirections - } - }, + router: { name: 'avoid', args: {} }, connector: { name: 'rounded' }, attrs: { line: { @@ -261,24 +248,7 @@ const DiagramContent = () => { const link = new shapes.standard.Link({ source: { id: sourceId, port: sourcePort }, target: { id: targetId, port: targetPort }, - router: { - name: routerType, - args: { - startDirections: ['left', 'right'], - endDirections: ['left', 'right'], - step: paper.options.gridSize || 8, - padding: routerPadding, - maximumLoops: routerTries, - // Fixed obstacle detection function - isPointObstacle: (point: dia.Point) => { - return obstacles.some(obstacle => { - // obstacle is already a Rect from inflate(), so we can use containsPoint directly - return obstacle.containsPoint(point); - }); - }, - maxAllowedDirectionChange: routerDirections - } - }, + router: { name: 'avoid', args: {} }, connector: { name: 'rounded' }, attrs: { line: { @@ -331,20 +301,7 @@ const DiagramContent = () => { // Update all links with new routing graph.getLinks().forEach(link => { - link.router(routerType, { - startDirections: ['left', 'right'], - endDirections: ['left', 'right'], - step: paper.options.gridSize || 8, - padding: routerPadding, - maximumLoops: routerTries, - // Fixed obstacle detection for rerouting - isPointObstacle: (point: dia.Point) => { - return obstacles.some(obstacle => { - return obstacle.containsPoint(point); - }); - }, - maxAllowedDirectionChange: routerDirections - }); + link.router("avoid"); }); }, 100); graph.on('change:position change:size change:attrs.line', reroute); diff --git a/Website/components/diagram/avoid-router/avoidrouter.ts b/Website/components/diagram/avoid-router/avoidrouter.ts new file mode 100644 index 0000000..d3803c1 --- /dev/null +++ b/Website/components/diagram/avoid-router/avoidrouter.ts @@ -0,0 +1,389 @@ +import { AvoidLib } from 'libavoid-js'; +import { g, util, mvc, dia } from '@joint/core'; + +const defaultPin = 1; + +type Avoid = ReturnType; + +interface AvoidRouterOptions { + shapeBufferDistance?: number; + portOverflow?: number; + idealNudgingDistance?: number; + commitTransactions?: boolean; +} + +export class AvoidRouter { + graph: dia.Graph; + connDirections: Record; + shapeRefs: Record; + edgeRefs: Record; + pinIds: Record; + linksByPointer: Record; + avoidRouter: any; + avoidConnectorCallback: (ptr: any) => void; + id: number; + margin!: number; + portOverflow!: number; + commitTransactions: boolean; + graphListener?: mvc.Listener; + + static async load(): Promise { + await AvoidLib.load("/libavoid.wasm"); + } + + constructor(graph: dia.Graph, options: AvoidRouterOptions = {}) { + const Avoid = AvoidLib.getInstance(); + + this.graph = graph; + + this.connDirections = { + top: Avoid.ConnDirUp, + right: Avoid.ConnDirRight, + bottom: Avoid.ConnDirDown, + left: Avoid.ConnDirLeft, + all: Avoid.ConnDirAll, + }; + + this.shapeRefs = {}; + this.edgeRefs = {}; + this.pinIds = {}; + this.linksByPointer = {}; + this.avoidConnectorCallback = this.onAvoidConnectorChange.bind(this); + this.id = 100000; + this.commitTransactions = options.commitTransactions ?? true; + + this.createAvoidRouter(options); + } + + createAvoidRouter(options: AvoidRouterOptions = {}) { + const { + shapeBufferDistance = 0, + portOverflow = 0, + idealNudgingDistance = 10, + } = options; + + this.margin = shapeBufferDistance; + this.portOverflow = portOverflow; + + const Avoid = AvoidLib.getInstance(); + const router = new Avoid.Router(Avoid.OrthogonalRouting); + + router.setRoutingParameter(Avoid.idealNudgingDistance, idealNudgingDistance); + router.setRoutingParameter(Avoid.shapeBufferDistance, shapeBufferDistance); + router.setRoutingOption(Avoid.nudgeOrthogonalTouchingColinearSegments, false); + router.setRoutingOption(Avoid.performUnifyingNudgingPreprocessingStep, true); + router.setRoutingOption(Avoid.nudgeSharedPathsWithCommonEndPoint, true); + router.setRoutingOption(Avoid.nudgeOrthogonalSegmentsConnectedToShapes, true); + + this.avoidRouter = router; + } + + getAvoidRectFromElement(element: dia.Element): any { + const Avoid = AvoidLib.getInstance(); + const { x, y, width, height } = element.getBBox(); + return new Avoid.Rectangle( + new Avoid.Point(x, y), + new Avoid.Point(x + width, y + height) + ); + } + + getVerticesFromAvoidRoute(route: any): Array<{ x: number; y: number }> { + const vertices: Array<{ x: number; y: number }> = []; + for (let i = 1; i < route.size() - 1; i++) { + const { x, y } = route.get_ps(i); + vertices.push({ x, y }); + } + return vertices; + } + + updateShape(element: dia.Element): void { + const Avoid = AvoidLib.getInstance(); + const shapeRect = this.getAvoidRectFromElement(element); + + if (this.shapeRefs[element.id]) { + this.avoidRouter.moveShape(this.shapeRefs[element.id], shapeRect); + return; + } + + const shapeRef = new Avoid.ShapeRef(this.avoidRouter, shapeRect); + this.shapeRefs[element.id] = shapeRef; + + const centerPin = new Avoid.ShapeConnectionPin( + shapeRef, + defaultPin, + 0.5, + 0.5, + true, + 0, + Avoid.ConnDirAll + ); + centerPin.setExclusive(false); + + element.getPortGroupNames().forEach((group: string) => { + const portsPositions = element.getPortsPositions(group); + const { width, height } = element.size(); + const rect = new g.Rect(0, 0, width, height); + Object.keys(portsPositions).forEach((portId: string) => { + const { x, y } = portsPositions[portId]; + const side = rect.sideNearestToPoint({ x, y }); + const pin = new Avoid.ShapeConnectionPin( + shapeRef, + this.getConnectionPinId(element.id.toString(), portId), + x / width, + y / height, + true, + 0, + this.connDirections[side] + ); + pin.setExclusive(false); + }); + }); + } + + getConnectionPinId(elementId: dia.Cell.ID, portId: string): number { + const pinKey = `${elementId}:${portId}`; + if (pinKey in this.pinIds) return this.pinIds[pinKey]; + const pinId = this.id++; + this.pinIds[pinKey] = pinId; + return pinId; + } + + updateConnector(link: dia.Link): any { + const Avoid = AvoidLib.getInstance(); + + const { id: sourceId, port: sourcePortId = null } = link.source(); + const { id: targetId, port: targetPortId = null } = link.target(); + + if (!sourceId || !targetId) { + this.deleteConnector(link); + return null; + } + + const sourceConnEnd = new Avoid.ConnEnd( + this.shapeRefs[sourceId], + sourcePortId ? this.getConnectionPinId(sourceId, sourcePortId) : defaultPin + ); + + const targetConnEnd = new Avoid.ConnEnd( + this.shapeRefs[targetId], + targetPortId ? this.getConnectionPinId(targetId, targetPortId) : defaultPin + ); + + let connRef = this.edgeRefs[link.id]; + + if (!connRef) { + connRef = new Avoid.ConnRef(this.avoidRouter); + this.linksByPointer[connRef.g] = link; + this.edgeRefs[link.id] = connRef; + connRef.setCallback(this.avoidConnectorCallback, connRef); + } + + connRef.setSourceEndpoint(sourceConnEnd); + connRef.setDestEndpoint(targetConnEnd); + + return connRef; + } + + deleteConnector(link: dia.Link): void { + const connRef = this.edgeRefs[link.id]; + if (!connRef) return; + this.avoidRouter.deleteConnector(connRef); + delete this.linksByPointer[connRef.g]; + delete this.edgeRefs[link.id]; + } + + deleteShape(element: dia.Element): void { + const shapeRef = this.shapeRefs[element.id]; + if (!shapeRef) return; + this.avoidRouter.deleteShape(shapeRef); + delete this.shapeRefs[element.id]; + } + + getLinkAnchorDelta(element: dia.Element, portId: string | null, point: g.Point): g.Point { + let anchorPosition: g.Point; + const bbox = element.getBBox(); + if (portId) { + const port = element.getPort(portId); + const portPosition = element.getPortsPositions(port.group || '')[portId]; + anchorPosition = element.position().offset(portPosition); + } else { + anchorPosition = bbox.center(); + } + return point.difference(anchorPosition); + } + + routeLink(link: dia.Link): void { + const connRef = this.edgeRefs[link.id]; + if (!connRef) return; + + const route = connRef.displayRoute(); + const sourcePoint = new g.Point(route.get_ps(0)); + const targetPoint = new g.Point(route.get_ps(route.size() - 1)); + + const { id: sourceId, port: sourcePortId = null } = link.source(); + const { id: targetId, port: targetPortId = null } = link.target(); + + const sourceElement = link.getSourceElement(); + const targetElement = link.getTargetElement(); + + if (!sourceElement || !targetElement) return; + + const sourceAnchorDelta = this.getLinkAnchorDelta(sourceElement, sourcePortId, sourcePoint); + const targetAnchorDelta = this.getLinkAnchorDelta(targetElement, targetPortId, targetPoint); + + const linkAttributes: dia.Link.Attributes = { + source: { + id: sourceId, + port: sourcePortId || null, + anchor: { name: 'modelCenter', args: { dx: sourceAnchorDelta.x, dy: sourceAnchorDelta.y } }, + }, + target: { + id: targetId, + port: targetPortId || null, + anchor: { name: 'modelCenter', args: { dx: targetAnchorDelta.x, dy: targetAnchorDelta.y } }, + }, + }; + + if (this.isRouteValid(route, sourceElement, targetElement, sourcePortId, targetPortId)) { + linkAttributes.vertices = this.getVerticesFromAvoidRoute(route); + linkAttributes.router = null; + } else { + linkAttributes.vertices = []; + linkAttributes.router = { + name: 'rightAngle', + args: { + margin: this.margin - this.portOverflow, + }, + }; + } + + link.set(linkAttributes, { avoidRouter: true }); + } + + routeAll(): void { + this.graph.getElements().forEach((element) => this.updateShape(element)); + this.graph.getLinks().forEach((link) => this.updateConnector(link)); + this.avoidRouter.processTransaction(); + } + + resetLink(link: dia.Link): void { + const newAttributes = util.cloneDeep(link.attributes); + newAttributes.vertices = []; + newAttributes.router = null; + delete newAttributes.source.anchor; + delete newAttributes.target.anchor; + link.set(newAttributes, { avoidRouter: true }); + } + + addGraphListeners(): void { + this.removeGraphListeners(); + + const listener = new mvc.Listener(); + listener.listenTo(this.graph, { + remove: (cell: dia.Cell) => this.onCellRemoved(cell), + add: (cell: dia.Cell) => this.onCellAdded(cell), + change: (cell: dia.Cell, opt: any) => this.onCellChanged(cell, opt), + reset: (_: any, opt: { previousModels: dia.Cell[] }) => this.onGraphReset(opt.previousModels), + }); + + this.graphListener = listener; + } + + removeGraphListeners(): void { + this.graphListener?.stopListening(); + delete this.graphListener; + } + + onCellRemoved(cell: dia.Cell): void { + if (cell.isElement()) { + this.deleteShape(cell); + } else { + this.deleteConnector(cell as dia.Link); + } + this.avoidRouter.processTransaction(); + } + + onCellAdded(cell: dia.Cell): void { + if (cell.isElement()) { + this.updateShape(cell); + } else { + this.updateConnector(cell as dia.Link); + } + this.avoidRouter.processTransaction(); + } + + onCellChanged(cell: dia.Cell, opt: any): void { + if (opt.avoidRouter) return; + let needsRerouting = false; + if ('source' in cell.changed || 'target' in cell.changed) { + if (!cell.isLink()) return; + if (!this.updateConnector(cell as dia.Link)) { + this.resetLink(cell as dia.Link); + } + needsRerouting = true; + } + if ('position' in cell.changed || 'size' in cell.changed) { + if (!cell.isElement()) return; + this.updateShape(cell); + needsRerouting = true; + } + + if (this.commitTransactions && needsRerouting) { + this.avoidRouter.processTransaction(); + } + } + + onGraphReset(previousModels: dia.Cell[]): void { + previousModels.forEach((cell) => { + if (cell.isElement()) { + this.deleteShape(cell); + } else { + this.deleteConnector(cell as dia.Link); + } + }); + + this.routeAll(); + } + + onAvoidConnectorChange(connRefPtr: any): void { + const link = this.linksByPointer[connRefPtr]; + if (!link) return; + this.routeLink(link); + } + + isRouteValid( + route: any, + sourceElement: dia.Element, + targetElement: dia.Element, + sourcePortId: string | null, + targetPortId: string | null + ): boolean { + const size = route.size(); + if (size > 2) return true; + + const sourcePs = route.get_ps(0); + const targetPs = route.get_ps(size - 1); + + if (sourcePs.x !== targetPs.x && sourcePs.y !== targetPs.y) { + return false; + } + + const margin = this.margin; + + if ( + sourcePortId && + targetElement.getBBox().inflate(margin).containsPoint(sourcePs) + ) { + return false; + } + + if ( + targetPortId && + sourceElement.getBBox().inflate(margin).containsPoint(targetPs) + ) { + return false; + } + + return true; + } +} diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx new file mode 100644 index 0000000..e69de29 diff --git a/Website/contexts/SearchPerformanceContext.tsx b/Website/contexts/SearchPerformanceContext.tsx new file mode 100644 index 0000000..e69de29 diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 0b1d265..4c34e02 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -1,7 +1,8 @@ import { useRef, useState, useCallback, useEffect } from 'react'; -import { dia } from '@joint/core'; +import { dia, routers } from '@joint/core'; import { GroupType, EntityType, AttributeType } from '@/lib/Types'; import { EntityElement } from '@/components/diagram/entity/entity'; +import { AvoidRouter } from '@/components/diagram/avoid-router/avoidrouter'; export type DiagramType = 'detailed' | 'simple'; @@ -238,12 +239,40 @@ export const useDiagram = (): DiagramState & DiagramActions => { setDiagramType(type); }, []); - const initializePaper = useCallback((container: HTMLElement, options: any = {}) => { + const initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { // Create graph if it doesn't exist if (!graphRef.current) { graphRef.current = new dia.Graph(); } + await AvoidRouter.load(); + const 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; + // Create paper with light amber background const paper = new dia.Paper({ el: container, diff --git a/Website/public/libavoid.wasm b/Website/public/libavoid.wasm new file mode 100644 index 0000000000000000000000000000000000000000..5043d8771c08f174d6366374ad472c27df2755ae GIT binary patch literal 485460 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�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 R76j3!k~_VyQ Date: Thu, 7 Aug 2025 19:02:13 +0200 Subject: [PATCH 16/45] chore: jumpover and work on simple self referencing --- .../instructions/copilot.instructions.md | 4 + Website/components/diagram/DiagramView.tsx | 270 ++++++++---------- .../diagram/entity/SimpleEntityElement.ts | 73 ++++- 3 files changed, 184 insertions(+), 163 deletions(-) create mode 100644 Website/.github/instructions/copilot.instructions.md diff --git a/Website/.github/instructions/copilot.instructions.md b/Website/.github/instructions/copilot.instructions.md new file mode 100644 index 0000000..0df4db1 --- /dev/null +++ b/Website/.github/instructions/copilot.instructions.md @@ -0,0 +1,4 @@ +--- +applyTo: '**' +--- +Read ai-rules/README.md for instructions on how to use this file. \ No newline at end of file diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 6895903..758d1d1 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -10,13 +10,10 @@ import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { PanelLeft, ZoomIn, ZoomOut } from 'lucide-react'; import { DiagramCanvas } from '@/components/diagram/DiagramCanvas'; -import { GroupSelector } from '@/components/diagram/GroupSelector'; -import { DiagramResetButton } from '@/components/diagram/DiagramResetButton'; import { ZoomCoordinateIndicator } from '@/components/diagram/ZoomCoordinateIndicator'; import { AddAttributeModal } from '@/components/diagram/AddAttributeModal'; import { calculateGridLayout, getDefaultLayoutOptions, calculateEntityHeight } from '@/components/diagram/GridLayoutManager'; import { AttributeType } from '@/lib/Types'; -import { TooltipProvider } from '@radix-ui/react-tooltip'; import { AppSidebar } from '../AppSidebar'; import { DiagramViewProvider, useDiagramViewContext } from '@/contexts/DiagramViewContext'; import { SidebarDiagramView } from './SidebarDiagramView'; @@ -24,10 +21,7 @@ import { useSidebarDispatch } from '@/contexts/SidebarContext'; interface IDiagramView {} -// const routerType = "manhattan" const routerPadding = 16; -// const routerTries = 5000; -// const routerDirections = 90; const DiagramContent = () => { const { @@ -141,7 +135,16 @@ const DiagramContent = () => { data: { entity } }); entityElement.addTo(graph); - entityMap.set(entity.SchemaName, { element: entityElement, portMap: {} }); + + // Create port map with 4 directional ports for simple entities + const simplePortMap = { + top: 'port-top', + right: 'port-right', + bottom: 'port-bottom', + left: 'port-left' + }; + + entityMap.set(entity.SchemaName, { element: entityElement, portMap: simplePortMap }); } else { // Create detailed entity with attributes and ports const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); @@ -160,14 +163,6 @@ const DiagramContent = () => { el.get('type') !== 'background.dots' && el.get('type') !== 'standard.Link' ); - - // Create obstacles from entity bounding boxes with padding - const obstacles = entityElements.map(el => { - const bbox = el.getBBox(); - return bbox.inflate(routerPadding); // This returns a Rect, not an object with bbox() - }); - - console.log('Created obstacles:', obstacles.length, 'for entities:', entityElements.length); // Create relationships for (const entity of currentEntities) { @@ -177,54 +172,53 @@ const DiagramContent = () => { const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); if (diagramType === 'simple') { - // For simple diagram, create links between entity centers - for (const entity of currentEntities) { - const entityInfo = entityMap.get(entity.SchemaName); - if (!entityInfo) continue; + // Find all lookup attributes and create links + for (const attr of entity.Attributes) { + if (attr.AttributeType !== "LookupAttribute") continue; - // Find all lookup attributes and create links - for (const attr of entity.Attributes) { - if (attr.AttributeType !== "LookupAttribute") continue; - - for (const target of attr.Targets) { - const targetInfo = entityMap.get(target.Name); - if (!targetInfo) continue; - - const sourceId = entityInfo.element.id; - const targetId = targetInfo.element.id; - - // Create link connecting entity centers - const link = new shapes.standard.Link({ - source: { id: sourceId }, - target: { id: targetId }, - router: { name: 'avoid', args: {} }, - connector: { name: 'rounded' }, - attrs: { - line: { + for (const target of attr.Targets) { + const targetInfo = entityMap.get(target.Name); + if (!targetInfo) continue; + + const sourceId = entityInfo.element.id; + const targetId = targetInfo.element.id; + + // Create link connecting entity centers + const isSelfRef = sourceId === targetId; + const link = new shapes.standard.Link({ + source: isSelfRef + ? { id: sourceId, port: portMap.right } + : { id: sourceId }, + target: isSelfRef + ? { id: targetId, port: portMap.left } + : { id: targetId }, + 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, - sourceMarker: { - type: 'ellipse', - cx: -6, - cy: 0, - rx: 4, - ry: 4, - fill: '#fff', - stroke: '#42a5f5', - strokeWidth: 2, - }, - targetMarker: { - type: 'path', - d: 'M 10 -5 L 0 0 L 10 5 Z', - fill: '#42a5f5', - stroke: '#42a5f5' - } + }, + targetMarker: { + type: 'path', + d: 'M 10 -5 L 0 0 L 10 5 Z', + fill: '#42a5f5', + stroke: '#42a5f5' } } - }); + } + }); - link.addTo(graph); - } + link.addTo(graph); } } } else { @@ -244,76 +238,47 @@ const DiagramContent = () => { if (!sourcePort || !targetPort) continue; - // Use a filled arrow for 'many' side, and a small circle for 'one' side - const link = new shapes.standard.Link({ - source: { id: sourceId, port: sourcePort }, - target: { id: targetId, port: targetPort }, - router: { name: 'avoid', args: {} }, - connector: { name: 'rounded' }, - attrs: { - line: { - stroke: '#42a5f5', - strokeWidth: 2, - sourceMarker: { - type: 'ellipse', - cx: -6, - cy: 0, - rx: 4, - ry: 4, - fill: '#fff', + // Use a filled arrow for 'many' side, and a small circle for 'one' side + const link = new shapes.standard.Link({ + source: { id: sourceId, port: sourcePort }, + target: { id: targetId, port: targetPort }, + router: { name: 'avoid', args: {} }, + connector: { name: 'jumpover', args: { radius: 8 } }, + attrs: { + line: { stroke: '#42a5f5', strokeWidth: 2, - }, - targetMarker: { - type: 'path', - d: 'M 10 -5 L 0 0 L 10 5 Z', - fill: '#42a5f5', - stroke: '#42a5f5' + sourceMarker: { + type: 'ellipse', + cx: -6, + cy: 0, + rx: 4, + ry: 4, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + }, + targetMarker: { + type: 'path', + d: 'M 10 -5 L 0 0 L 10 5 Z', + fill: '#42a5f5', + stroke: '#42a5f5' + } } } - } - }); + }); - link.addTo(graph); + link.addTo(graph); + } } } } - } - }); - - // Fixed rerouting function - const reroute = debounce(() => { - console.log('Rerouting links...'); - - // Get all entity elements (exclude background dots and links) - const entityElements = graph.getElements().filter(el => - el.get('type') !== 'background.dots' && - el.get('type') !== 'standard.Link' - ); - - // Create fresh obstacles for rerouting - const obstacles = entityElements.map(el => { - const bbox = el.getBBox(); - return bbox.inflate(routerPadding); - }); - - console.log('Rerouting with obstacles:', obstacles.length); - - // Update all links with new routing - graph.getLinks().forEach(link => { - link.router("avoid"); - }); - }, 100); - graph.on('change:position change:size change:attrs.line', reroute); + }); // Auto-fit to screen after a short delay to ensure all elements are rendered setTimeout(() => { fitToScreen(); }, 200); - - return () => { - graph.off('change:position change:size change:attrs.line', reroute); - }; }, [graph, paper, selectedGroup, currentEntities, diagramType]); useEffect(() => { @@ -336,49 +301,44 @@ const DiagramContent = () => { ); if (entityWithKey) { - // Find all links that target this entity's key port - const targetEntityId = graph.getElements().find(el => - (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') && - el.get('data')?.entity?.SchemaName === entityWithKey.SchemaName - )?.id; - - if (targetEntityId) { - if (diagramType === 'simple') { - // For simple diagram, highlight all links to this entity - const linksToHighlight = graph.getLinks().filter(link => { - const target = link.target(); - return target.id === targetEntityId; - }); - - // Apply highlighting to the found links - linksToHighlight.forEach(link => { - link.attr('line/stroke', '#ff6b6b'); // Red color for highlighted links - link.attr('line/strokeWidth', 4); // Thicker stroke for highlighted links - }); - - console.log(`Highlighted ${linksToHighlight.length} links targeting entity: ${entityWithKey.SchemaName}`); - } else { - // For detailed diagram, highlight links to specific key port - const targetPort = `port-${selectedKey.toLowerCase()}`; - - const linksToHighlight = graph.getLinks().filter(link => { - const target = link.target(); - return target.id === targetEntityId && target.port === targetPort; - }); - - // Apply highlighting to the found links - linksToHighlight.forEach(link => { - link.attr('line/stroke', '#ff6b6b'); // Red color for highlighted links - link.attr('line/strokeWidth', 4); // Thicker stroke for highlighted links - link.attr('line/targetMarker/stroke', '#ff6b6b'); - link.attr('line/targetMarker/fill', '#ff6b6b'); - link.attr('line/sourceMarker/stroke', '#ff6b6b'); - }); - - console.log(`Highlighted ${linksToHighlight.length} links targeting key: ${selectedKey}`); + const targetEntityId = graph.getElements().find(el => + (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') && + el.get('data')?.entity?.SchemaName === entityWithKey.SchemaName + )?.id; + + if (targetEntityId) { + if (diagramType === 'simple') { + // For simple diagram, highlight all links to this entity + const linksToHighlight = graph.getLinks().filter(link => { + const target = link.target(); + return target.id === targetEntityId; + }); + + // Apply highlighting to the found links + linksToHighlight.forEach(link => { + link.attr('line/stroke', '#ff6b6b'); // Red color for highlighted links + link.attr('line/strokeWidth', 4); // Thicker stroke for highlighted links + }); + } else { + // For detailed diagram, highlight links to specific key port + const targetPort = `port-${selectedKey.toLowerCase()}`; + + const linksToHighlight = graph.getLinks().filter(link => { + const target = link.target(); + return target.id === targetEntityId && target.port === targetPort; + }); + + // Apply highlighting to the found links + linksToHighlight.forEach(link => { + link.attr('line/stroke', '#ff6b6b'); // Red color for highlighted links + link.attr('line/strokeWidth', 4); // Thicker stroke for highlighted links + link.attr('line/targetMarker/stroke', '#ff6b6b'); + link.attr('line/targetMarker/fill', '#ff6b6b'); + link.attr('line/sourceMarker/stroke', '#ff6b6b'); + }); + } } } - } }, [selectedKey, graph, currentEntities]); // Update entity elements when selectedKey changes diff --git a/Website/components/diagram/entity/SimpleEntityElement.ts b/Website/components/diagram/entity/SimpleEntityElement.ts index ec299eb..0017461 100644 --- a/Website/components/diagram/entity/SimpleEntityElement.ts +++ b/Website/components/diagram/entity/SimpleEntityElement.ts @@ -11,6 +11,70 @@ export class SimpleEntityElement extends dia.Element { 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: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + visibility: 'hidden', // Make port invisible + }, + }, + }, + left: { + position: { name: 'left' }, + attrs: { + circle: { + r: 6, + magnet: true, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + visibility: 'hidden', // Make port invisible + }, + }, + }, + right: { + position: { name: 'right' }, + attrs: { + circle: { + r: 6, + magnet: true, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + visibility: 'hidden', // Make port invisible + }, + }, + }, + bottom: { + position: { name: 'bottom' }, + attrs: { + circle: { + r: 6, + magnet: true, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + visibility: 'hidden', // Make port invisible + }, + }, + }, + }, + items: [ + { id: 'port-top', group: 'top' }, + { id: 'port-left', group: 'left' }, + { id: 'port-right', group: 'right' }, + { id: 'port-bottom', group: 'bottom' }, + ], + }); } updateEntity(entity: EntityType) { @@ -48,16 +112,9 @@ export class SimpleEntityElement extends dia.Element { } private createSimpleEntityHTML(entity: EntityType): string { - const icon = entity.IconBase64 != null - ? `data:image/svg+xml;base64,${entity.IconBase64}` - : '/vercel.svg'; - return `

-
- -

${entity.DisplayName}

${entity.SchemaName}

@@ -87,4 +144,4 @@ export class SimpleEntityElement extends dia.Element { markup: [] // dynamic in updateEntity }; } -} \ No newline at end of file +} \ No newline at end of file From 0746342aee019f5fb8884715dcad6fa2c26d852f Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Thu, 7 Aug 2025 19:56:44 +0200 Subject: [PATCH 17/45] chore: refactor diagramview into abstraction renderes --- Website/components/diagram/DiagramRenderer.ts | 46 +++ Website/components/diagram/DiagramView.tsx | 313 +++--------------- .../diagram/entity/SimpleEntityElement.ts | 10 +- .../renderers/DetailedDiagramRender.ts | 147 ++++++++ .../diagram/renderers/SimpleDiagramRender.ts | 116 +++++++ Website/hooks/useDiagram.ts | 2 + 6 files changed, 364 insertions(+), 270 deletions(-) create mode 100644 Website/components/diagram/DiagramRenderer.ts create mode 100644 Website/components/diagram/renderers/DetailedDiagramRender.ts create mode 100644 Website/components/diagram/renderers/SimpleDiagramRender.ts diff --git a/Website/components/diagram/DiagramRenderer.ts b/Website/components/diagram/DiagramRenderer.ts new file mode 100644 index 0000000..fa0d544 --- /dev/null +++ b/Website/components/diagram/DiagramRenderer.ts @@ -0,0 +1,46 @@ +import { dia } from '@joint/core'; +import { AttributeType, EntityType } from '@/lib/Types'; + +export type IPortMap = Record; + +export abstract class DiagramRenderer { + protected graph: dia.Graph; + protected setSelectedKey?: (key: string | undefined) => void; + protected setSelectedEntityForAttribute?: (schema: string) => void; + protected setIsAddAttributeModalOpen?: (open: boolean) => void; + + constructor( + graph: dia.Graph | undefined | null, + options?: { + setSelectedKey?: (key: string | undefined) => void; + setSelectedEntityForAttribute?: (schema: string) => void; + setIsAddAttributeModalOpen?: (open: boolean) => void; + }) { + if (!graph) throw new Error("Graph must be defined"); + this.graph = graph; + this.setSelectedKey = options?.setSelectedKey; + this.setSelectedEntityForAttribute = options?.setSelectedEntityForAttribute; + this.setIsAddAttributeModalOpen = options?.setIsAddAttributeModalOpen; + } + + 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): void; + + abstract highlightSelectedKey( + graph: dia.Graph, + entities: EntityType[], + selectedKey: string + ): void; + + abstract updateEntityAttributes(graph: dia.Graph, selectedKey: string): void; + + abstract onLinkClick(linkView: dia.LinkView, evt: dia.Event): void; + + abstract getVisibleAttributes(entity: EntityType): AttributeType[]; +} \ No newline at end of file diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 758d1d1..88ac486 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { dia, shapes, util } from '@joint/core' import { Groups } from "../../generated/Data" import { EntityElement } from '@/components/diagram/entity/entity'; @@ -18,11 +18,11 @@ import { AppSidebar } from '../AppSidebar'; import { DiagramViewProvider, useDiagramViewContext } from '@/contexts/DiagramViewContext'; import { SidebarDiagramView } from './SidebarDiagramView'; import { useSidebarDispatch } from '@/contexts/SidebarContext'; +import { SimpleDiagramRenderer } from './renderers/SimpleDiagramRender'; +import { DetailedDiagramRender } from './renderers/DetailedDiagramRender'; interface IDiagramView {} -const routerPadding = 16; - const DiagramContent = () => { const { graph, @@ -45,51 +45,41 @@ const DiagramContent = () => { const [isAddAttributeModalOpen, setIsAddAttributeModalOpen] = useState(false); const [selectedEntityForAttribute, setSelectedEntityForAttribute] = useState(); - // Auto-select first group on mount + 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, + setSelectedEntityForAttribute, + setIsAddAttributeModalOpen + }); + }, [diagramType, graph, setSelectedKey, setSelectedEntityForAttribute, setIsAddAttributeModalOpen]); + useEffect(() => { - if (Groups.length > 0 && !selectedGroup) { - selectGroup(Groups[0]); - } + if (Groups.length > 0 && !selectedGroup) selectGroup(Groups[0]); }, [Groups, selectedGroup, selectGroup]); useEffect(() => { - const handleDocumentClick = (e: MouseEvent) => { - const target = (e.target as HTMLElement).closest('button[data-schema-name]') as HTMLElement; - const addButton = (e.target as HTMLElement).closest('button[data-add-attribute]') as HTMLElement; - - if (addButton) { - // Find the entity this add button belongs to - const entityElement = addButton.closest('[data-entity-schema]') as HTMLElement; - if (entityElement) { - const entitySchema = entityElement.dataset.entitySchema; - setSelectedEntityForAttribute(entitySchema); - setIsAddAttributeModalOpen(true); - } - return; - } - - if (target) { - const schemaName = target.dataset.schemaName!; - const isKey = target.dataset.isKey === 'true'; - - if (isKey) { - setSelectedKey(schemaName); - } - } else { - // Clicked outside of any key attribute, clear selection - setSelectedKey(undefined); - } - }; - - document.addEventListener('click', handleDocumentClick); - + if (!renderer) return; + document.addEventListener('click', renderer.onDocumentClick); return () => { - document.removeEventListener('click', handleDocumentClick); + document.removeEventListener('click', renderer.onDocumentClick); }; - }, []); + }, [renderer]); useEffect(() => { - if (!graph || !paper || !selectedGroup) return; + if (!graph || !paper || !selectedGroup || !renderer) return; // Clear existing elements graph.clear(); @@ -123,156 +113,17 @@ const DiagramContent = () => { // Store entity elements and port maps by SchemaName for easy lookup const entityMap = new Map(); - // Create entities in grid layout currentEntities.forEach((entity, index) => { const position = layout.positions[index] || { x: 50, y: 50 }; - - if (diagramType === 'simple') { - // Create simple entity (no attributes, no ports) - const entityElement = new SimpleEntityElement({ - position, - data: { entity } - }); - entityElement.addTo(graph); - - // Create port map with 4 directional ports for simple entities - const simplePortMap = { - top: 'port-top', - right: 'port-right', - bottom: 'port-bottom', - left: 'port-left' - }; - - entityMap.set(entity.SchemaName, { element: entityElement, portMap: simplePortMap }); - } else { - // Create detailed entity with attributes and ports - const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); - const entityElement = new EntityElement({ - position, - data: { entity, setSelectedKey, selectedKey } - }); - entityElement.addTo(graph); - entityMap.set(entity.SchemaName, { element: entityElement, portMap }); - } + const { element, portMap } = renderer.createEntity(entity, position); + entityMap.set(entity.SchemaName, { element, portMap }); }); util.nextFrame(() => { - // Get all entity elements (exclude background dots and links) - const entityElements = graph.getElements().filter(el => - el.get('type') !== 'background.dots' && - el.get('type') !== 'standard.Link' - ); - - // Create relationships - for (const entity of currentEntities) { - const entityInfo = entityMap.get(entity.SchemaName); - if (!entityInfo) continue; - const { portMap } = entityInfo; - const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); - - if (diagramType === 'simple') { - // Find all lookup attributes and create links - for (const attr of entity.Attributes) { - if (attr.AttributeType !== "LookupAttribute") continue; - - for (const target of attr.Targets) { - const targetInfo = entityMap.get(target.Name); - if (!targetInfo) continue; - - const sourceId = entityInfo.element.id; - const targetId = targetInfo.element.id; - - // Create link connecting entity centers - const isSelfRef = sourceId === targetId; - const link = new shapes.standard.Link({ - source: isSelfRef - ? { id: sourceId, port: portMap.right } - : { id: sourceId }, - target: isSelfRef - ? { id: targetId, port: portMap.left } - : { id: targetId }, - 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 10 -5 L 0 0 L 10 5 Z', - fill: '#42a5f5', - stroke: '#42a5f5' - } - } - } - }); - - link.addTo(graph); - } - } - } else { - // For detailed diagram, use ports - 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`]; - const sourceId = entityInfo.element.id; - const targetId = targetInfo.element.id; - - if (!sourcePort || !targetPort) continue; - - // Use a filled arrow for 'many' side, and a small circle for 'one' side - const link = new shapes.standard.Link({ - source: { id: sourceId, port: sourcePort }, - target: { id: targetId, 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 10 -5 L 0 0 L 10 5 Z', - fill: '#42a5f5', - stroke: '#42a5f5' - } - } - } - }); - - link.addTo(graph); - } - } - } - } + currentEntities.forEach(entity => { + renderer.createLinks(entity, entityMap); + }); }); // Auto-fit to screen after a short delay to ensure all elements are rendered @@ -282,9 +133,8 @@ const DiagramContent = () => { }, [graph, paper, selectedGroup, currentEntities, diagramType]); useEffect(() => { - if (!selectedKey || !graph) return; + if (!selectedKey || !graph || !renderer) return; - // Reset all links to default styling graph.getLinks().forEach(link => { link.attr('line/stroke', '#42a5f5'); link.attr('line/strokeWidth', 2); @@ -293,89 +143,21 @@ const DiagramContent = () => { link.attr('line/sourceMarker/stroke', '#42a5f5'); }); - // Find the entity that contains the selected key - const entityWithKey = currentEntities.find(entity => - entity.Attributes.some(attr => - attr.SchemaName === selectedKey && attr.IsPrimaryId - ) - ); - - if (entityWithKey) { - const targetEntityId = graph.getElements().find(el => - (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') && - el.get('data')?.entity?.SchemaName === entityWithKey.SchemaName - )?.id; - - if (targetEntityId) { - if (diagramType === 'simple') { - // For simple diagram, highlight all links to this entity - const linksToHighlight = graph.getLinks().filter(link => { - const target = link.target(); - return target.id === targetEntityId; - }); - - // Apply highlighting to the found links - linksToHighlight.forEach(link => { - link.attr('line/stroke', '#ff6b6b'); // Red color for highlighted links - link.attr('line/strokeWidth', 4); // Thicker stroke for highlighted links - }); - } else { - // For detailed diagram, highlight links to specific key port - const targetPort = `port-${selectedKey.toLowerCase()}`; - - const linksToHighlight = graph.getLinks().filter(link => { - const target = link.target(); - return target.id === targetEntityId && target.port === targetPort; - }); - - // Apply highlighting to the found links - linksToHighlight.forEach(link => { - link.attr('line/stroke', '#ff6b6b'); // Red color for highlighted links - link.attr('line/strokeWidth', 4); // Thicker stroke for highlighted links - link.attr('line/targetMarker/stroke', '#ff6b6b'); - link.attr('line/targetMarker/fill', '#ff6b6b'); - link.attr('line/sourceMarker/stroke', '#ff6b6b'); - }); - } - } - } - }, [selectedKey, graph, currentEntities]); + renderer.highlightSelectedKey(graph, currentEntities, selectedKey); + }, [selectedKey, graph, currentEntities, renderer]); - // Update entity elements when selectedKey changes useEffect(() => { - if (!graph || !selectedKey) return; - - // Update all entity elements with the new selectedKey - graph.getElements().forEach(el => { - if (el.get('type') === 'delegate.entity') { - const currentData = el.get('data'); - el.set('data', { ...currentData, selectedKey }); - - // Trigger re-render of the entity - const entityElement = el as EntityElement; - if (entityElement.updateAttributes) { - entityElement.updateAttributes(currentData.entity); - } - } else if (el.get('type') === 'delegate.simple-entity') { - // Simple entities don't need key highlighting updates - // They don't have attributes to highlight - } - }); - }, [selectedKey, graph]); + if (!graph || !selectedKey || !renderer) return; + renderer.updateEntityAttributes(graph, selectedKey); + }, [selectedKey, graph, renderer]); useEffect(() => { - if (!paper) return; - // Add link click handler - const onLinkClick = (linkView: any, evt: any) => { - evt.stopPropagation(); - // Placeholder: show relationship info and hide option coming soon! - alert('Relationship info and hide option coming soon!'); - }; - paper.on('link:pointerclick', onLinkClick); + if (!paper || !renderer) return; + paper.on('link:pointerclick', renderer.onLinkClick); return () => { - paper.off('link:pointerclick', onLinkClick); + paper.off('link:pointerclick', renderer.onLinkClick); }; - }, [paper]); + }, [paper, renderer]); const handleAddAttribute = (attribute: AttributeType) => { if (!selectedEntityForAttribute) return; @@ -390,8 +172,9 @@ const DiagramContent = () => { // Get available and visible attributes for the selected entity const availableAttributes = selectedEntity?.Attributes || []; - const visibleAttributes = selectedEntity ? - EntityElement.getVisibleItemsAndPorts(selectedEntity).visibleItems : []; + const visibleAttributes = selectedEntity && renderer + ? renderer.getVisibleAttributes(selectedEntity) + : []; return ( <> diff --git a/Website/components/diagram/entity/SimpleEntityElement.ts b/Website/components/diagram/entity/SimpleEntityElement.ts index 0017461..9a9beec 100644 --- a/Website/components/diagram/entity/SimpleEntityElement.ts +++ b/Website/components/diagram/entity/SimpleEntityElement.ts @@ -24,12 +24,12 @@ export class SimpleEntityElement extends dia.Element { fill: '#fff', stroke: '#42a5f5', strokeWidth: 2, - visibility: 'hidden', // Make port invisible + visibility: 'hidden', }, }, }, left: { - position: { name: 'left' }, + position: { name: 'left', args: { dx: 6 } }, attrs: { circle: { r: 6, @@ -37,7 +37,7 @@ export class SimpleEntityElement extends dia.Element { fill: '#fff', stroke: '#42a5f5', strokeWidth: 2, - visibility: 'hidden', // Make port invisible + visibility: 'hidden', }, }, }, @@ -50,7 +50,7 @@ export class SimpleEntityElement extends dia.Element { fill: '#fff', stroke: '#42a5f5', strokeWidth: 2, - visibility: 'hidden', // Make port invisible + visibility: 'hidden', }, }, }, @@ -63,7 +63,7 @@ export class SimpleEntityElement extends dia.Element { fill: '#fff', stroke: '#42a5f5', strokeWidth: 2, - visibility: 'hidden', // Make port invisible + visibility: 'hidden', }, }, }, diff --git a/Website/components/diagram/renderers/DetailedDiagramRender.ts b/Website/components/diagram/renderers/DetailedDiagramRender.ts new file mode 100644 index 0000000..c35429d --- /dev/null +++ b/Website/components/diagram/renderers/DetailedDiagramRender.ts @@ -0,0 +1,147 @@ +// DetailedDiagramRender.ts +import { dia, shapes } from '@joint/core'; +import { SimpleEntityElement } from '@/components/diagram/entity/SimpleEntityElement'; +import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; +import { EntityElement } from '../entity/entity'; +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; + const addButton = (event.target as HTMLElement).closest('button[data-add-attribute]') as HTMLElement; + + if (addButton) { + // Find the entity this add button belongs to + const entityElement = addButton.closest('[data-entity-schema]') as HTMLElement; + if (entityElement) { + const entitySchema = entityElement.dataset.entitySchema; + this.setSelectedEntityForAttribute?.(entitySchema!); + this.setIsAddAttributeModalOpen?.(true); + } + return; + } + + if (target) { + const schemaName = target.dataset.schemaName!; + const isKey = target.dataset.isKey === 'true'; + + if (isKey) this.setSelectedKey?.(schemaName); + } else { + this.setSelectedKey?.(undefined); + } + } + + createEntity(entity: EntityType, position: { x: number, y: number }) { + const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); + const entityElement = new EntityElement({ + position, + data: { entity } + }); + + entityElement.addTo(this.graph); + return { element: entityElement, portMap }; + } + + createLinks(entity: EntityType, entityMap: Map) { + const entityInfo = entityMap.get(entity.SchemaName); + if (!entityInfo) return; + + const { portMap } = entityInfo; + const { visibleItems } = EntityElement.getVisibleItemsAndPorts(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; + + 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' + } + } + } + }); + + 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; + + const portId = `port-${selectedKey.toLowerCase()}`; + graph.getLinks().forEach(link => { + const target = link.target(); + if (target.id === entityId && 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'); + } + }); + } + + updateEntityAttributes(graph: dia.Graph, selectedKey: string): 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(); + alert('Relationship info (detailed view)'); + } + + getVisibleAttributes(entity: EntityType): AttributeType[] { + return EntityElement.getVisibleItemsAndPorts(entity).visibleItems; + } +} diff --git a/Website/components/diagram/renderers/SimpleDiagramRender.ts b/Website/components/diagram/renderers/SimpleDiagramRender.ts new file mode 100644 index 0000000..9ab727d --- /dev/null +++ b/Website/components/diagram/renderers/SimpleDiagramRender.ts @@ -0,0 +1,116 @@ +// SimpleDiagramRenderer.ts +import { dia, shapes } from '@joint/core'; +import { SimpleEntityElement } from '@/components/diagram/entity/SimpleEntityElement'; +import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; +import { EntityElement } from '../entity/entity'; +import { AttributeType, EntityType } from '@/lib/Types'; + +export class SimpleDiagramRenderer extends DiagramRenderer { + + onDocumentClick(event: MouseEvent): 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) { + const entityInfo = entityMap.get(entity.SchemaName); + if (!entityInfo) return; + + for (const attr of entity.Attributes) { + 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; + + 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' + } + } + } + }); + + 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.simple-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(graph: dia.Graph, selectedKey: string): void { + // Simple entities don't display key attributes, so nothing to do + } + + onLinkClick(linkView: dia.LinkView, evt: dia.Event): void { + evt.stopPropagation(); + alert('Relationship info (simple view)'); + } + + getVisibleAttributes(entity: EntityType): AttributeType[] { + return entity.Attributes; + } +} diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 4c34e02..06d47d4 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -3,6 +3,8 @@ import { dia, routers } from '@joint/core'; import { GroupType, EntityType, AttributeType } from '@/lib/Types'; import { EntityElement } from '@/components/diagram/entity/entity'; import { AvoidRouter } from '@/components/diagram/avoid-router/avoidrouter'; +import { DiagramRenderer } from '@/components/diagram/DiagramRenderer'; +import { SimpleDiagramRenderer } from '@/components/diagram/renderers/SimpleDiagramRender'; export type DiagramType = 'detailed' | 'simple'; From e2a0c51167582584ae992ba1ac2f9e5e2fd8f43c Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 10:35:35 +0200 Subject: [PATCH 18/45] chore: new sidebar and add/remove entity functionality. Also moved "add attribute" logic to entitypane --- Website/components/diagram/DiagramRenderer.ts | 6 - Website/components/diagram/DiagramView.tsx | 88 +++---- .../components/diagram/SidebarDiagramView.tsx | 187 +++++++++------ .../diagram/entity/EntityAttribute.ts | 11 +- .../components/diagram/entity/EntityBody.ts | 1 - Website/components/diagram/entity/entity.ts | 3 +- .../AddAttributePane.tsx} | 6 +- .../diagram/panes/AddEntityPane.tsx | 123 ++++++++++ .../diagram/panes/EntityActionsPane.tsx | 219 ++++++++++++++++++ Website/components/diagram/panes/README.md | 50 ++++ Website/components/diagram/panes/index.ts | 5 + .../renderers/DetailedDiagramRender.ts | 14 +- Website/hooks/useDiagram.ts | 46 ++++ 13 files changed, 613 insertions(+), 146 deletions(-) rename Website/components/diagram/{AddAttributeModal.tsx => panes/AddAttributePane.tsx} (98%) create mode 100644 Website/components/diagram/panes/AddEntityPane.tsx create mode 100644 Website/components/diagram/panes/EntityActionsPane.tsx create mode 100644 Website/components/diagram/panes/README.md create mode 100644 Website/components/diagram/panes/index.ts diff --git a/Website/components/diagram/DiagramRenderer.ts b/Website/components/diagram/DiagramRenderer.ts index fa0d544..ce03e19 100644 --- a/Website/components/diagram/DiagramRenderer.ts +++ b/Website/components/diagram/DiagramRenderer.ts @@ -6,21 +6,15 @@ export type IPortMap = Record; export abstract class DiagramRenderer { protected graph: dia.Graph; protected setSelectedKey?: (key: string | undefined) => void; - protected setSelectedEntityForAttribute?: (schema: string) => void; - protected setIsAddAttributeModalOpen?: (open: boolean) => void; constructor( graph: dia.Graph | undefined | null, options?: { setSelectedKey?: (key: string | undefined) => void; - setSelectedEntityForAttribute?: (schema: string) => void; - setIsAddAttributeModalOpen?: (open: boolean) => void; }) { if (!graph) throw new Error("Graph must be defined"); this.graph = graph; this.setSelectedKey = options?.setSelectedKey; - this.setSelectedEntityForAttribute = options?.setSelectedEntityForAttribute; - this.setIsAddAttributeModalOpen = options?.setIsAddAttributeModalOpen; } abstract onDocumentClick(event: MouseEvent): void; diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 88ac486..4f63969 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -8,10 +8,10 @@ import { SimpleEntityElement } from '@/components/diagram/entity/SimpleEntityEle import debounce from 'lodash/debounce'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { PanelLeft, ZoomIn, ZoomOut } from 'lucide-react'; +import { PanelLeft, ZoomIn, ZoomOut, Trash2 } from 'lucide-react'; import { DiagramCanvas } from '@/components/diagram/DiagramCanvas'; import { ZoomCoordinateIndicator } from '@/components/diagram/ZoomCoordinateIndicator'; -import { AddAttributeModal } from '@/components/diagram/AddAttributeModal'; +import { AddEntityPane, EntityActionsPane } from '@/components/diagram/panes'; import { calculateGridLayout, getDefaultLayoutOptions, calculateEntityHeight } from '@/components/diagram/GridLayoutManager'; import { AttributeType } from '@/lib/Types'; import { AppSidebar } from '../AppSidebar'; @@ -37,13 +37,13 @@ const DiagramContent = () => { resetView, fitToScreen, addAttributeToEntity, - diagramType + diagramType, + removeEntityFromDiagram } = useDiagramViewContext(); const [selectedKey, setSelectedKey] = useState(); - const [isSidebarOpen, setIsSidebarOpen] = useState(true); - const [isAddAttributeModalOpen, setIsAddAttributeModalOpen] = useState(false); - const [selectedEntityForAttribute, setSelectedEntityForAttribute] = useState(); + const [selectedEntityForActions, setSelectedEntityForActions] = useState(); + const [isEntityActionsSheetOpen, setIsEntityActionsSheetOpen] = useState(false); const renderer = useMemo(() => { if (!graph) return null; @@ -60,11 +60,9 @@ const DiagramContent = () => { })(); return new RendererClass(graph, { - setSelectedKey, - setSelectedEntityForAttribute, - setIsAddAttributeModalOpen + setSelectedKey }); - }, [diagramType, graph, setSelectedKey, setSelectedEntityForAttribute, setIsAddAttributeModalOpen]); + }, [diagramType, graph, setSelectedKey]); useEffect(() => { if (Groups.length > 0 && !selectedGroup) selectGroup(Groups[0]); @@ -153,21 +151,48 @@ const DiagramContent = () => { useEffect(() => { if (!paper || !renderer) return; + + // Handle link clicks paper.on('link:pointerclick', renderer.onLinkClick); + + // Handle entity clicks + const handleEntityClick = (elementView: any, evt: any) => { + evt.stopPropagation(); + const element = elementView.model; + const entityData = element.get('data'); + + if (entityData?.entity) { + setSelectedEntityForActions(entityData.entity.SchemaName); + setIsEntityActionsSheetOpen(true); + } + }; + + paper.on('element:pointerclick', handleEntityClick); + return () => { paper.off('link:pointerclick', renderer.onLinkClick); + paper.off('element:pointerclick', handleEntityClick); }; }, [paper, renderer]); const handleAddAttribute = (attribute: AttributeType) => { - if (!selectedEntityForAttribute) return; - addAttributeToEntity(selectedEntityForAttribute, attribute); - setIsAddAttributeModalOpen(false); - setSelectedEntityForAttribute(undefined); + if (!selectedEntityForActions) return; + addAttributeToEntity(selectedEntityForActions, attribute); }; + const handleDeleteEntity = () => { + if (selectedEntityForActions) { + removeEntityFromDiagram(selectedEntityForActions); + setIsEntityActionsSheetOpen(false); + setSelectedEntityForActions(undefined); + } + }; + + // 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 === selectedEntityForAttribute); + const selectedEntity = currentEntities.find(entity => entity.SchemaName === selectedEntityForActions); const selectedEntityName = selectedEntity?.DisplayName; // Get available and visible attributes for the selected entity @@ -185,28 +210,6 @@ const DiagramContent = () => {

Data Model Diagram

-
- Group: - - {selectedGroup?.Name || 'None'} - -
- -
- Entities: - - {currentEntities.length} - -
-
-
-
@@ -227,12 +230,13 @@ const DiagramContent = () => {
- {/* Add Attribute Modal */} - setIsAddAttributeModalOpen(false)} + {/* Entity Actions Pane */} + diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx index 7ae20c7..19760f8 100644 --- a/Website/components/diagram/SidebarDiagramView.tsx +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -1,83 +1,132 @@ -import { Groups } from "@/generated/Data" -import { Separator } from "@radix-ui/react-select" -import { GroupSelector } from "./GroupSelector" -import { useDiagramViewContext } from "@/contexts/DiagramViewContext"; -import { ZoomOut, ZoomIn, Layers, Database } from "lucide-react"; -import { Button } from "../ui/button"; -import { DiagramResetButton } from "./DiagramResetButton"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import React, { useState, useMemo } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Button } from '@/components/ui/button'; +import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer } from 'lucide-react'; +import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; +import { AddEntityPane } from '@/components/diagram/panes'; +import { DiagramType } from '@/hooks/useDiagram'; interface ISidebarDiagramViewProps { } export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { - - const { selectedGroup, selectGroup, zoomOut, zoomIn, zoom, resetView, diagramType, updateDiagramType } = useDiagramViewContext(); + const { addEntityToDiagram, currentEntities, diagramType, updateDiagramType } = useDiagramViewContext(); + const [isDataExpanded, setIsDataExpanded] = useState(true); + const [isGeneralExpanded, setIsGeneralExpanded] = useState(false); + const [isEntitySheetOpen, setIsEntitySheetOpen] = useState(false); return ( -
-
- -
+
+ + + + + + + + + + + + + + + {/* Data Section */} + + + + + + + + - - -
-

Diagram Type

- -
+
+
+ + - - -
-

Controls

-
- - -
-
- -
-
+ {/* Add Entity Pane */} +
- ) + ); } \ No newline at end of file diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/entity/EntityAttribute.ts index 495408c..c91d8c0 100644 --- a/Website/components/diagram/entity/EntityAttribute.ts +++ b/Website/components/diagram/entity/EntityAttribute.ts @@ -6,16 +6,7 @@ interface IEntityAttribute { isSelected?: boolean; } -export const EntityAttribute = ({ attribute, isKey, isSelected = false, isAddButton = false }: IEntityAttribute & { isAddButton?: boolean }): string => { - if (isAddButton) { - return ` - - `; - } - +export const EntityAttribute = ({ attribute, isKey, isSelected = false }: IEntityAttribute): string => { let icon = ''; if (isKey) { icon = `` diff --git a/Website/components/diagram/entity/EntityBody.ts b/Website/components/diagram/entity/EntityBody.ts index 5676f6b..f283292 100644 --- a/Website/components/diagram/entity/EntityBody.ts +++ b/Website/components/diagram/entity/EntityBody.ts @@ -33,7 +33,6 @@ export function EntityBody({ entity, visibleItems, selectedKey }: IEntityBody): isKey: i == 0, isSelected: selectedKey === attribute.SchemaName }))).join('')} - ${EntityAttribute({ attribute: { DisplayName: '', SchemaName: '', AttributeType: 'GenericAttribute' } as any, isKey: false, isAddButton: true })}
`; diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index e138b4b..1919dab 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -59,12 +59,11 @@ export class EntityElement extends dia.Element { const itemHeight = 28; const itemYSpacing = 8; - const addButtonHeight = 32; // Space for add button const headerHeight = 80; const startY = headerHeight + itemYSpacing * 2; // Calculate height dynamically based on number of visible items - const height = startY + visibleItems.length * (itemHeight + itemYSpacing) + addButtonHeight + itemYSpacing; + const height = startY + visibleItems.length * (itemHeight + itemYSpacing) + itemYSpacing; const leftPorts: dia.Element.Port[] = []; const rightPorts: dia.Element.Port[] = []; diff --git a/Website/components/diagram/AddAttributeModal.tsx b/Website/components/diagram/panes/AddAttributePane.tsx similarity index 98% rename from Website/components/diagram/AddAttributeModal.tsx rename to Website/components/diagram/panes/AddAttributePane.tsx index 3622c10..f53648c 100644 --- a/Website/components/diagram/AddAttributeModal.tsx +++ b/Website/components/diagram/panes/AddAttributePane.tsx @@ -28,7 +28,7 @@ import { } from 'lucide-react'; import { AttributeType } from '@/lib/Types'; -export interface AddAttributeModalProps { +export interface AddAttributePaneProps { isOpen: boolean; onClose: () => void; onAddAttribute: (attribute: AttributeType) => void; @@ -67,7 +67,7 @@ const getAttributeTypeLabel = (attributeType: string) => { } }; -export const AddAttributeModal: React.FC = ({ +export const AddAttributePane: React.FC = ({ isOpen, onClose, onAddAttribute, @@ -180,4 +180,4 @@ export const AddAttributeModal: React.FC = ({ ); -}; \ No newline at end of file +}; diff --git a/Website/components/diagram/panes/AddEntityPane.tsx b/Website/components/diagram/panes/AddEntityPane.tsx new file mode 100644 index 0000000..f29eeab --- /dev/null +++ b/Website/components/diagram/panes/AddEntityPane.tsx @@ -0,0 +1,123 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Search } from 'lucide-react'; +import { Groups } from '@/generated/Data'; +import { EntityType, GroupType } from '@/lib/Types'; + +export interface AddEntityPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onAddEntity: (entity: EntityType) => void; + currentEntities: EntityType[]; +} + +export const AddEntityPane: React.FC = ({ + isOpen, + onOpenChange, + onAddEntity, + currentEntities +}) => { + const [searchTerm, setSearchTerm] = useState(''); + + // 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) => { + onAddEntity(entity); + onOpenChange(false); + }; + + return ( + + + + Add Entity to Diagram + +
+ {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Groups and Entities List */} +
+ {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. +
+ )} +
+
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/EntityActionsPane.tsx b/Website/components/diagram/panes/EntityActionsPane.tsx new file mode 100644 index 0000000..3dbb55e --- /dev/null +++ b/Website/components/diagram/panes/EntityActionsPane.tsx @@ -0,0 +1,219 @@ +'use client'; + +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + Trash2, + Plus, + ChevronDown, + ChevronRight, + Type, + Calendar, + Hash, + Search, + DollarSign, + ToggleLeft, + FileText, + List, + Activity +} from 'lucide-react'; +import { EntityType, AttributeType } from '@/lib/Types'; + +export interface EntityActionsPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + selectedEntity: EntityType | null; + onDeleteEntity: () => void; + onAddAttribute?: (attribute: AttributeType) => void; + availableAttributes?: AttributeType[]; + visibleAttributes?: AttributeType[]; +} + +const getAttributeIcon = (attributeType: string) => { + switch (attributeType) { + case 'StringAttribute': return Type; + case 'IntegerAttribute': return Hash; + case 'DecimalAttribute': return DollarSign; + case 'DateTimeAttribute': return Calendar; + case 'BooleanAttribute': return ToggleLeft; + case 'ChoiceAttribute': return List; + case 'LookupAttribute': return Search; + case 'FileAttribute': return FileText; + case 'StatusAttribute': return Activity; + default: return Type; + } +}; + +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, + availableAttributes = [], + visibleAttributes = [] +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isAttributesExpanded, setIsAttributesExpanded] = 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); + } + }; + + return ( + + + + + Entity Actions + + + {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..." + className="text-sm" + /> +
+ + {/* 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 && ( + + +
+ ? +
+
+ +

{attribute.Description}

+
+
+ )} +
+ ); + }) + )} +
+
+
+ )} + + +
+
+ +
+
+

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/diagram/panes/README.md b/Website/components/diagram/panes/README.md new file mode 100644 index 0000000..47804ea --- /dev/null +++ b/Website/components/diagram/panes/README.md @@ -0,0 +1,50 @@ +# Diagram Panes + +This folder contains reusable pane components used in the diagram view. These components are extracted from the main DiagramView component to improve code organization and reusability. + +## Components + +### AddEntityPane +- **Purpose**: Provides a sheet interface for adding entities to the diagram from the available entity groups +- **Features**: + - Search functionality for filtering entities and groups + - Shows entity information (name, schema, description) + - Filters out already added entities with visual indicators + - Group-based organization of entities + +### EntityActionsPane +- **Purpose**: Provides a sheet interface for performing actions on selected entities, including adding attributes +- **Features**: + - Entity information display (name, schema, description) + - Delete/remove entity action + - Entity statistics (attributes count, relationships count, etc.) + - **Add Attribute functionality**: Collapsible section to add existing attributes to the entity + - Search functionality for filtering available attributes + - Attribute type icons and descriptions + - Tooltip support for attribute descriptions + +## Usage + +Import the components from the panes index: + +```tsx +import { AddEntityPane, EntityActionsPane } from '@/components/diagram/panes'; +``` + +### EntityActionsPane with Attribute Support + +The EntityActionsPane now includes optional attribute management: + +```tsx + +``` + +Each pane component is designed to be controlled by parent components through props for open/close state and callback functions. diff --git a/Website/components/diagram/panes/index.ts b/Website/components/diagram/panes/index.ts new file mode 100644 index 0000000..b95d1e3 --- /dev/null +++ b/Website/components/diagram/panes/index.ts @@ -0,0 +1,5 @@ +export { AddEntityPane } from './AddEntityPane'; +export { EntityActionsPane } from './EntityActionsPane'; + +export type { AddEntityPaneProps } from './AddEntityPane'; +export type { EntityActionsPaneProps } from './EntityActionsPane'; diff --git a/Website/components/diagram/renderers/DetailedDiagramRender.ts b/Website/components/diagram/renderers/DetailedDiagramRender.ts index c35429d..2f2a7d3 100644 --- a/Website/components/diagram/renderers/DetailedDiagramRender.ts +++ b/Website/components/diagram/renderers/DetailedDiagramRender.ts @@ -9,19 +9,7 @@ export class DetailedDiagramRender extends DiagramRenderer { onDocumentClick(event: MouseEvent): void { const target = (event.target as HTMLElement).closest('button[data-schema-name]') as HTMLElement; - const addButton = (event.target as HTMLElement).closest('button[data-add-attribute]') as HTMLElement; - - if (addButton) { - // Find the entity this add button belongs to - const entityElement = addButton.closest('[data-entity-schema]') as HTMLElement; - if (entityElement) { - const entitySchema = entityElement.dataset.entitySchema; - this.setSelectedEntityForAttribute?.(entitySchema!); - this.setIsAddAttributeModalOpen?.(true); - } - return; - } - + if (target) { const schemaName = target.dataset.schemaName!; const isKey = target.dataset.isKey === 'true'; diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 06d47d4..2be22ac 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -5,6 +5,7 @@ import { EntityElement } from '@/components/diagram/entity/entity'; import { AvoidRouter } from '@/components/diagram/avoid-router/avoidrouter'; import { DiagramRenderer } from '@/components/diagram/DiagramRenderer'; import { SimpleDiagramRenderer } from '@/components/diagram/renderers/SimpleDiagramRender'; +import { DetailedDiagramRender } from '@/components/diagram/renderers/DetailedDiagramRender'; export type DiagramType = 'detailed' | 'simple'; @@ -37,6 +38,8 @@ export interface DiagramActions { updatePanPosition: (position: { x: number; y: number }) => void; addAttributeToEntity: (entitySchemaName: string, attribute: AttributeType) => void; updateDiagramType: (type: DiagramType) => void; + addEntityToDiagram: (entity: EntityType) => void; + removeEntityFromDiagram: (entitySchemaName: string) => void; } export const useDiagram = (): DiagramState & DiagramActions => { @@ -241,6 +244,47 @@ export const useDiagram = (): DiagramState & DiagramActions => { setDiagramType(type); }, []); + const addEntityToDiagram = useCallback((entity: EntityType) => { + 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 + } + + // Update current entities + const updatedEntities = [...currentEntities, entity]; + setCurrentEntities(updatedEntities); + }, [currentEntities, diagramType, fitToScreen]); + + 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('type') === 'delegate.simple-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 initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { // Create graph if it doesn't exist if (!graphRef.current) { @@ -424,5 +468,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { updatePanPosition, addAttributeToEntity, updateDiagramType, + addEntityToDiagram, + removeEntityFromDiagram, }; }; \ No newline at end of file From 26f52dc870832e4a095c6c3731ce5ffc76d718aa Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 10:57:35 +0200 Subject: [PATCH 19/45] chore: hover indication on entities --- Website/components/diagram/DiagramView.tsx | 53 +++++++++++++++++++++ Website/components/diagram/entity/entity.ts | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 4f63969..827d1da 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -157,6 +157,15 @@ const DiagramContent = () => { // Handle entity clicks const handleEntityClick = (elementView: any, evt: any) => { + // 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; + } + evt.stopPropagation(); const element = elementView.model; const entityData = element.get('data'); @@ -166,12 +175,56 @@ const DiagramContent = () => { setIsEntityActionsSheetOpen(true); } }; + + // Handle entity hover for cursor indication + const handleEntityMouseEnter = (elementView: any) => { + const element = elementView.model; + const entityData = element.get('data'); + + if (entityData?.entity) { + // Change cursor on the SVG element + elementView.el.style.cursor = 'pointer'; + + // Find the foreignObject and its HTML content for the border effect + const foreignObject = elementView.el.querySelector('foreignObject'); + const htmlContent = foreignObject?.querySelector('[data-entity-schema]'); + + if (htmlContent && !htmlContent.hasAttribute('data-hover-active')) { + htmlContent.setAttribute('data-hover-active', 'true'); + htmlContent.style.border = '1px solid #3b82f6'; + htmlContent.style.borderRadius = '10px'; + } + } + }; + + const handleEntityMouseLeave = (elementView: any) => { + const element = elementView.model; + const entityData = element.get('data'); + + if (entityData?.entity) { + // Remove hover styling + elementView.el.style.cursor = 'default'; + + // Remove border from HTML content + const foreignObject = elementView.el.querySelector('foreignObject'); + const htmlContent = foreignObject?.querySelector('[data-entity-schema]'); + + if (htmlContent) { + htmlContent.removeAttribute('data-hover-active'); + htmlContent.style.border = 'none'; + } + } + }; paper.on('element:pointerclick', handleEntityClick); + paper.on('element:mouseenter', handleEntityMouseEnter); + paper.on('element:mouseleave', handleEntityMouseLeave); return () => { paper.off('link:pointerclick', renderer.onLinkClick); paper.off('element:pointerclick', handleEntityClick); + paper.off('element:mouseenter', handleEntityMouseEnter); + paper.off('element:mouseleave', handleEntityMouseLeave); }; }, [paper, renderer]); diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index 1919dab..5a5b01f 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -63,7 +63,7 @@ export class EntityElement extends dia.Element { const startY = headerHeight + itemYSpacing * 2; // Calculate height dynamically based on number of visible items - const height = startY + visibleItems.length * (itemHeight + itemYSpacing) + itemYSpacing; + const height = startY + visibleItems.length * (itemHeight + itemYSpacing) + 2; const leftPorts: dia.Element.Port[] = []; const rightPorts: dia.Element.Port[] = []; From ba84e1c8530f3dfbea7cc21267ab4b1d9757f2aa Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 11:08:33 +0200 Subject: [PATCH 20/45] chore: add entire group to diagram option --- .../components/diagram/SidebarDiagramView.tsx | 23 ++- .../diagram/panes/AddAttributePane.tsx | 183 ------------------ .../components/diagram/panes/AddGroupPane.tsx | 156 +++++++++++++++ Website/components/diagram/panes/index.ts | 2 + Website/hooks/useDiagram.ts | 21 ++ 5 files changed, 199 insertions(+), 186 deletions(-) delete mode 100644 Website/components/diagram/panes/AddAttributePane.tsx create mode 100644 Website/components/diagram/panes/AddGroupPane.tsx diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx index 19760f8..0c525e4 100644 --- a/Website/components/diagram/SidebarDiagramView.tsx +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -2,9 +2,9 @@ import React, { useState, useMemo } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Button } from '@/components/ui/button'; -import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer } from 'lucide-react'; +import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer, Users } from 'lucide-react'; import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; -import { AddEntityPane } from '@/components/diagram/panes'; +import { AddEntityPane, AddGroupPane } from '@/components/diagram/panes'; import { DiagramType } from '@/hooks/useDiagram'; interface ISidebarDiagramViewProps { @@ -12,10 +12,11 @@ interface ISidebarDiagramViewProps { } export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { - const { addEntityToDiagram, currentEntities, diagramType, updateDiagramType } = useDiagramViewContext(); + const { addEntityToDiagram, addGroupToDiagram, currentEntities, diagramType, updateDiagramType } = useDiagramViewContext(); const [isDataExpanded, setIsDataExpanded] = useState(true); const [isGeneralExpanded, setIsGeneralExpanded] = useState(false); const [isEntitySheetOpen, setIsEntitySheetOpen] = useState(false); + const [isGroupSheetOpen, setIsGroupSheetOpen] = useState(false); return (
@@ -50,6 +51,14 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { Entity + @@ -127,6 +136,14 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { onAddEntity={addEntityToDiagram} currentEntities={currentEntities} /> + + {/* Add Group Pane */} +
); } \ No newline at end of file diff --git a/Website/components/diagram/panes/AddAttributePane.tsx b/Website/components/diagram/panes/AddAttributePane.tsx deleted file mode 100644 index f53648c..0000000 --- a/Website/components/diagram/panes/AddAttributePane.tsx +++ /dev/null @@ -1,183 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter -} from '@/components/ui/sheet'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent } from '@/components/ui/card'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { - Type, - Calendar, - Hash, - Search, - DollarSign, - ToggleLeft, - FileText, - List, - Activity, - Plus -} from 'lucide-react'; -import { AttributeType } from '@/lib/Types'; - -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 Type; - case 'IntegerAttribute': return Hash; - case 'DecimalAttribute': return DollarSign; - case 'DateTimeAttribute': return Calendar; - case 'BooleanAttribute': return ToggleLeft; - case 'ChoiceAttribute': return List; - case 'LookupAttribute': return Search; - case 'FileAttribute': return FileText; - case 'StatusAttribute': return Activity; - default: return Type; - } -}; - -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 */} -
- - setSearchQuery(e.target.value)} - placeholder="Search by attribute name..." - /> -
- - {/* Available Attributes */} -
- -
-
- {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)} - > - -
-
- -
-
-
- {attribute.DisplayName} -
-
- {typeLabel} • {attribute.SchemaName} -
-
- {attribute.Description && ( - - -
- ? -
-
- -

{attribute.Description}

-
-
- )} -
-
-
- ); - })} -
- )} -
-
-
- - - - -
-
-
-
- ); -}; diff --git a/Website/components/diagram/panes/AddGroupPane.tsx b/Website/components/diagram/panes/AddGroupPane.tsx new file mode 100644 index 0000000..e205d84 --- /dev/null +++ b/Website/components/diagram/panes/AddGroupPane.tsx @@ -0,0 +1,156 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Search, Database } from 'lucide-react'; +import { Groups } from '@/generated/Data'; +import { EntityType, GroupType } from '@/lib/Types'; + +export interface AddGroupPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onAddGroup: (group: GroupType) => void; + currentEntities: EntityType[]; +} + +export const AddGroupPane: React.FC = ({ + isOpen, + onOpenChange, + onAddGroup, + currentEntities +}) => { + const [searchTerm, setSearchTerm] = useState(''); + + // 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) => { + onAddGroup(group); + 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 ( + + + + Add Group to Diagram + +
+ {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* 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/diagram/panes/index.ts b/Website/components/diagram/panes/index.ts index b95d1e3..3391d0d 100644 --- a/Website/components/diagram/panes/index.ts +++ b/Website/components/diagram/panes/index.ts @@ -1,5 +1,7 @@ export { AddEntityPane } from './AddEntityPane'; +export { AddGroupPane } from './AddGroupPane'; export { EntityActionsPane } from './EntityActionsPane'; export type { AddEntityPaneProps } from './AddEntityPane'; +export type { AddGroupPaneProps } from './AddGroupPane'; export type { EntityActionsPaneProps } from './EntityActionsPane'; diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 2be22ac..52c04c7 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -39,6 +39,7 @@ export interface DiagramActions { addAttributeToEntity: (entitySchemaName: string, attribute: AttributeType) => void; updateDiagramType: (type: DiagramType) => void; addEntityToDiagram: (entity: EntityType) => void; + addGroupToDiagram: (group: GroupType) => void; removeEntityFromDiagram: (entitySchemaName: string) => void; } @@ -260,6 +261,25 @@ export const useDiagram = (): DiagramState & DiagramActions => { setCurrentEntities(updatedEntities); }, [currentEntities, diagramType, fitToScreen]); + const addGroupToDiagram = useCallback((group: GroupType) => { + 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 + } + + // Update current entities with new entities from the group + const updatedEntities = [...currentEntities, ...newEntities]; + setCurrentEntities(updatedEntities); + }, [currentEntities]); + const removeEntityFromDiagram = useCallback((entitySchemaName: string) => { if (!graphRef.current) { return; @@ -469,6 +489,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { addAttributeToEntity, updateDiagramType, addEntityToDiagram, + addGroupToDiagram, removeEntityFromDiagram, }; }; \ No newline at end of file From 887b7718ec4e9f47fcccf4560e098ab6d47132a6 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 11:32:25 +0200 Subject: [PATCH 21/45] chore: more attribute control. Choose initial attributes. And remove attributes option --- Website/components/diagram/DiagramView.tsx | 7 + Website/components/diagram/entity/entity.ts | 41 ++- .../diagram/panes/AddEntityPane.tsx | 283 +++++++++++++++--- .../diagram/panes/EntityActionsPane.tsx | 64 ++++ Website/components/ui/checkbox.tsx | 30 ++ Website/hooks/useDiagram.ts | 150 ++++++++-- Website/package-lock.json | 192 ++++++++++++ Website/package.json | 2 + 8 files changed, 696 insertions(+), 73 deletions(-) create mode 100644 Website/components/ui/checkbox.tsx diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 827d1da..a14398f 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -37,6 +37,7 @@ const DiagramContent = () => { resetView, fitToScreen, addAttributeToEntity, + removeAttributeFromEntity, diagramType, removeEntityFromDiagram } = useDiagramViewContext(); @@ -233,6 +234,11 @@ const DiagramContent = () => { addAttributeToEntity(selectedEntityForActions, attribute); }; + const handleRemoveAttribute = (attribute: AttributeType) => { + if (!selectedEntityForActions) return; + removeAttributeFromEntity(selectedEntityForActions, attribute); + }; + const handleDeleteEntity = () => { if (selectedEntityForActions) { removeEntityFromDiagram(selectedEntityForActions); @@ -290,6 +296,7 @@ const DiagramContent = () => { selectedEntity={selectedEntityForActionsData || null} onDeleteEntity={handleDeleteEntity} onAddAttribute={handleAddAttribute} + onRemoveAttribute={handleRemoveAttribute} availableAttributes={availableAttributes} visibleAttributes={visibleAttributes} /> diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/entity.ts index 5a5b01f..a3c37ea 100644 --- a/Website/components/diagram/entity/entity.ts +++ b/Website/components/diagram/entity/entity.ts @@ -15,25 +15,48 @@ export class EntityElement extends dia.Element { } static getVisibleItemsAndPorts(entity: EntityType) { + // Get the visible attributes list - if not set, use default logic + const visibleAttributeSchemaNames = (entity as any).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 - ); - - // Get manually added attributes (stored in entity metadata) - const manuallyAddedAttributes = entity.Attributes.filter(attr => - (entity as any).manuallyAddedAttributes?.includes(attr.SchemaName) + attr.AttributeType === "LookupAttribute" && + attr.IsCustomAttribute ); - // Combine primary key, custom lookup attributes, and manually added attributes + // Combine primary key and custom lookup attributes const visibleItems = [ primaryKeyAttribute, - ...customLookupAttributes, - ...manuallyAddedAttributes + ...customLookupAttributes ]; // Map SchemaName to port name diff --git a/Website/components/diagram/panes/AddEntityPane.tsx b/Website/components/diagram/panes/AddEntityPane.tsx index f29eeab..d979981 100644 --- a/Website/components/diagram/panes/AddEntityPane.tsx +++ b/Website/components/diagram/panes/AddEntityPane.tsx @@ -3,18 +3,23 @@ import React, { useState, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { Search } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Search, ChevronDown, ChevronRight, Settings } from 'lucide-react'; import { Groups } from '@/generated/Data'; -import { EntityType, GroupType } from '@/lib/Types'; +import { EntityType, GroupType, AttributeType } from '@/lib/Types'; export interface AddEntityPaneProps { isOpen: boolean; onOpenChange: (open: boolean) => void; - onAddEntity: (entity: EntityType) => void; + onAddEntity: (entity: EntityType, selectedAttributes?: string[]) => void; currentEntities: EntityType[]; } +type AttributeSelectionMode = 'minimal' | 'custom-lookups' | 'all-lookups' | 'custom'; + export const AddEntityPane: React.FC = ({ isOpen, onOpenChange, @@ -22,6 +27,10 @@ export const AddEntityPane: React.FC = ({ currentEntities }) => { const [searchTerm, setSearchTerm] = useState(''); + const [attributeMode, setAttributeMode] = useState('custom-lookups'); + const [selectedEntity, setSelectedEntity] = useState(null); + const [customSelectedAttributes, setCustomSelectedAttributes] = useState([]); + const [isAttributeSettingsExpanded, setIsAttributeSettingsExpanded] = useState(false); // Filter groups and entities based on search term const filteredData = useMemo(() => { @@ -44,8 +53,54 @@ export const AddEntityPane: React.FC = ({ }, [searchTerm]); const handleAddEntity = (entity: EntityType) => { - onAddEntity(entity); + let selectedAttributes: string[] = []; + + // Determine which attributes to include based on mode + switch (attributeMode) { + case 'minimal': + // Only primary key (handled by default in useDiagram) + selectedAttributes = []; + break; + case 'custom-lookups': + selectedAttributes = entity.Attributes + .filter(attr => attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute) + .map(attr => attr.SchemaName); + break; + case 'all-lookups': + selectedAttributes = entity.Attributes + .filter(attr => attr.AttributeType === "LookupAttribute") + .map(attr => attr.SchemaName); + break; + case 'custom': + selectedAttributes = customSelectedAttributes; + break; + } + + onAddEntity(entity, selectedAttributes); onOpenChange(false); + setSelectedEntity(null); + setCustomSelectedAttributes([]); + }; + + const handleEntityClick = (entity: EntityType) => { + if (attributeMode === 'custom') { + setSelectedEntity(entity); + // Initialize with current default (custom lookups) + const defaultSelected = entity.Attributes + .filter(attr => attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute) + .map(attr => attr.SchemaName); + setCustomSelectedAttributes(defaultSelected); + } else { + handleAddEntity(entity); + } + }; + + const handleCustomAttributeToggle = (attributeSchemaName: string, checked: boolean) => { + if (checked) { + setCustomSelectedAttributes(prev => [...prev, attributeSchemaName]); + } else { + setCustomSelectedAttributes(prev => prev.filter(name => name !== attributeSchemaName)); + } }; return ( @@ -55,6 +110,70 @@ export const AddEntityPane: React.FC = ({ Add Entity to Diagram
+ {/* Attribute Selection Options */} + + + + + +
+ +
+
+ setAttributeMode('minimal')} + className="w-4 h-4" + /> + +
+
+ setAttributeMode('custom-lookups')} + className="w-4 h-4" + /> + +
+
+ setAttributeMode('all-lookups')} + className="w-4 h-4" + /> + +
+
+ setAttributeMode('custom')} + className="w-4 h-4" + /> + +
+
+
+
+
+ {/* Search Input */}
@@ -67,55 +186,129 @@ export const AddEntityPane: React.FC = ({
{/* Groups and Entities List */} -
- {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, checked) + } + /> +
+
+ {attribute.DisplayName} + {isPrimaryKey && ( + + Primary Key + + )} + {attribute.AttributeType === "LookupAttribute" && ( + + Lookup )}
- - ); - })} -
+

{attribute.SchemaName}

+ {attribute.Description && ( +

{attribute.Description}

+ )} +
+
+ ); + })}
- ))} - {filteredData.length === 0 && ( -
- No entities found matching your search. + +
+
- )} -
+
+ )}
diff --git a/Website/components/diagram/panes/EntityActionsPane.tsx b/Website/components/diagram/panes/EntityActionsPane.tsx index 3dbb55e..d0ea73b 100644 --- a/Website/components/diagram/panes/EntityActionsPane.tsx +++ b/Website/components/diagram/panes/EntityActionsPane.tsx @@ -30,6 +30,7 @@ export interface EntityActionsPaneProps { selectedEntity: EntityType | null; onDeleteEntity: () => void; onAddAttribute?: (attribute: AttributeType) => void; + onRemoveAttribute?: (attribute: AttributeType) => void; availableAttributes?: AttributeType[]; visibleAttributes?: AttributeType[]; } @@ -70,11 +71,13 @@ export const EntityActionsPane: React.FC = ({ 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); @@ -91,6 +94,17 @@ export const EntityActionsPane: React.FC = ({ } }; + 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 ( @@ -186,6 +200,56 @@ export const EntityActionsPane: React.FC = ({ )} + + {/* Remove Attribute Section */} + {onRemoveAttribute && removableAttributes.length > 0 && ( + + + + + + {/* Removable Attributes */} +
+ {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. +
+
+
+ )} - diff --git a/Website/components/diagram/avoid-router/avoidrouter.ts b/Website/components/diagram/avoid-router/avoidrouter.ts index d3803c1..e75eb39 100644 --- a/Website/components/diagram/avoid-router/avoidrouter.ts +++ b/Website/components/diagram/avoid-router/avoidrouter.ts @@ -97,6 +97,11 @@ export class AvoidRouter { } updateShape(element: dia.Element): void { + // Skip squares - they shouldn't be obstacles for routing + if (element.get('type') === 'delegate.square') { + return; + } + const Avoid = AvoidLib.getInstance(); const shapeRect = this.getAvoidRectFromElement(element); diff --git a/Website/components/diagram/entity/entity.ts b/Website/components/diagram/entity/EntityElement.ts similarity index 100% rename from Website/components/diagram/entity/entity.ts rename to Website/components/diagram/entity/EntityElement.ts diff --git a/Website/components/diagram/entity/SquareElement.ts b/Website/components/diagram/entity/SquareElement.ts new file mode 100644 index 0000000..085b322 --- /dev/null +++ b/Website/components/diagram/entity/SquareElement.ts @@ -0,0 +1,290 @@ +import { dia, util } from '@joint/core'; +import { PRESET_COLORS } from '../panes/SquarePropertiesPane'; + +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: any[]) { + 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/diagram/entity/SquareElementView.ts b/Website/components/diagram/entity/SquareElementView.ts new file mode 100644 index 0000000..2d3ec4a --- /dev/null +++ b/Website/components/diagram/entity/SquareElementView.ts @@ -0,0 +1,42 @@ +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/diagram/panes/README.md b/Website/components/diagram/panes/README.md deleted file mode 100644 index 47804ea..0000000 --- a/Website/components/diagram/panes/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Diagram Panes - -This folder contains reusable pane components used in the diagram view. These components are extracted from the main DiagramView component to improve code organization and reusability. - -## Components - -### AddEntityPane -- **Purpose**: Provides a sheet interface for adding entities to the diagram from the available entity groups -- **Features**: - - Search functionality for filtering entities and groups - - Shows entity information (name, schema, description) - - Filters out already added entities with visual indicators - - Group-based organization of entities - -### EntityActionsPane -- **Purpose**: Provides a sheet interface for performing actions on selected entities, including adding attributes -- **Features**: - - Entity information display (name, schema, description) - - Delete/remove entity action - - Entity statistics (attributes count, relationships count, etc.) - - **Add Attribute functionality**: Collapsible section to add existing attributes to the entity - - Search functionality for filtering available attributes - - Attribute type icons and descriptions - - Tooltip support for attribute descriptions - -## Usage - -Import the components from the panes index: - -```tsx -import { AddEntityPane, EntityActionsPane } from '@/components/diagram/panes'; -``` - -### EntityActionsPane with Attribute Support - -The EntityActionsPane now includes optional attribute management: - -```tsx - -``` - -Each pane component is designed to be controlled by parent components through props for open/close state and callback functions. diff --git a/Website/components/diagram/panes/SquarePropertiesPane.tsx b/Website/components/diagram/panes/SquarePropertiesPane.tsx new file mode 100644 index 0000000..4e531a4 --- /dev/null +++ b/Website/components/diagram/panes/SquarePropertiesPane.tsx @@ -0,0 +1,252 @@ +import React, { useState, useEffect } from 'react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { Palette, Square, Trash2 } from 'lucide-react'; +import { SquareElement, SquareElementData } from '../entity/SquareElement'; + +export interface SquarePropertiesPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + selectedSquare: SquareElement | null; + onDeleteSquare?: () => void; +} + +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 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: any) => { + 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 ( + + + + + + Square Properties + + + +
+ {/* Fill Color Section */} +
+ +
+ {PRESET_COLORS.fills.map((color) => ( + + ))} +
+
+ handleDataChange('fillColor', e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleDataChange('fillColor', e.target.value)} + placeholder="#f1f5f9" + className="flex-1 text-sm" + /> +
+
+ + + + {/* Border Section */} +
+ + + {/* Border Color */} +
+ +
+ {PRESET_COLORS.borders.map((color) => ( + + ))} +
+
+ handleDataChange('borderColor', e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleDataChange('borderColor', e.target.value)} + placeholder="#64748b" + className="flex-1 text-sm" + /> +
+
+ + {/* Border Width */} +
+ + handleDataChange('borderWidth', parseInt(e.target.value) || 0)} + className="text-sm" + /> +
+ + {/* Border Type */} +
+ + +
+
+ + + + {/* Opacity Section */} +
+ +
+ handleDataChange('opacity', parseFloat(e.target.value))} + className="w-full" + /> +
+ {Math.round((squareData.opacity || 0.7) * 100)}% +
+
+
+ + + + {/* Delete Section */} +
+ + +
+
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/index.ts b/Website/components/diagram/panes/index.ts index 3391d0d..b65d948 100644 --- a/Website/components/diagram/panes/index.ts +++ b/Website/components/diagram/panes/index.ts @@ -1,7 +1,9 @@ export { AddEntityPane } from './AddEntityPane'; export { AddGroupPane } from './AddGroupPane'; export { EntityActionsPane } from './EntityActionsPane'; +export { SquarePropertiesPane } from './SquarePropertiesPane'; export type { AddEntityPaneProps } from './AddEntityPane'; export type { AddGroupPaneProps } from './AddGroupPane'; export type { EntityActionsPaneProps } from './EntityActionsPane'; +export type { SquarePropertiesPaneProps } from './SquarePropertiesPane'; diff --git a/Website/components/diagram/renderers/DetailedDiagramRender.ts b/Website/components/diagram/renderers/DetailedDiagramRender.ts index 2f2a7d3..5433e4d 100644 --- a/Website/components/diagram/renderers/DetailedDiagramRender.ts +++ b/Website/components/diagram/renderers/DetailedDiagramRender.ts @@ -2,7 +2,7 @@ import { dia, shapes } from '@joint/core'; import { SimpleEntityElement } from '@/components/diagram/entity/SimpleEntityElement'; import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; -import { EntityElement } from '../entity/entity'; +import { EntityElement } from '../entity/EntityElement'; import { AttributeType, EntityType } from '@/lib/Types'; export class DetailedDiagramRender extends DiagramRenderer { diff --git a/Website/components/diagram/renderers/SimpleDiagramRender.ts b/Website/components/diagram/renderers/SimpleDiagramRender.ts index 9ab727d..51c328f 100644 --- a/Website/components/diagram/renderers/SimpleDiagramRender.ts +++ b/Website/components/diagram/renderers/SimpleDiagramRender.ts @@ -2,7 +2,7 @@ import { dia, shapes } from '@joint/core'; import { SimpleEntityElement } from '@/components/diagram/entity/SimpleEntityElement'; import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; -import { EntityElement } from '../entity/entity'; +import { EntityElement } from '../entity/EntityElement'; import { AttributeType, EntityType } from '@/lib/Types'; export class SimpleDiagramRenderer extends DiagramRenderer { diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 734999d..42ea760 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -1,7 +1,10 @@ import { useRef, useState, useCallback, useEffect } from 'react'; import { dia, routers } from '@joint/core'; import { GroupType, EntityType, AttributeType } from '@/lib/Types'; -import { EntityElement } from '@/components/diagram/entity/entity'; +import { EntityElement } from '@/components/diagram/entity/EntityElement'; +import { SquareElement } from '@/components/diagram/entity/SquareElement'; +import { SquareElementView } from '@/components/diagram/entity/SquareElementView'; +import { PRESET_COLORS } from '@/components/diagram/panes/SquarePropertiesPane'; import { AvoidRouter } from '@/components/diagram/avoid-router/avoidrouter'; import { DiagramRenderer } from '@/components/diagram/DiagramRenderer'; import { SimpleDiagramRenderer } from '@/components/diagram/renderers/SimpleDiagramRender'; @@ -42,6 +45,7 @@ export interface DiagramActions { addEntityToDiagram: (entity: EntityType, selectedAttributes?: string[]) => void; addGroupToDiagram: (group: GroupType) => void; removeEntityFromDiagram: (entitySchemaName: string) => void; + addSquareToDiagram: () => void; } export const useDiagram = (): DiagramState & DiagramActions => { @@ -416,6 +420,51 @@ export const useDiagram = (): DiagramState & DiagramActions => { } }, [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 initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { // Create graph if it doesn't exist if (!graphRef.current) { @@ -461,6 +510,24 @@ export const useDiagram = (): DiagramState & DiagramActions => { 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 }); @@ -603,5 +670,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { addEntityToDiagram, addGroupToDiagram, removeEntityFromDiagram, + addSquareToDiagram, }; }; \ No newline at end of file diff --git a/Website/package.json b/Website/package.json index c8a0da0..e9d984d 100644 --- a/Website/package.json +++ b/Website/package.json @@ -26,7 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^5.9.6", - "lodash": "^4.17.21", + "libavoid-js": "^0.4.5", "lucide-react": "^0.462.0", "next": "^15.3.4", "react": "^19.0.0", @@ -43,7 +43,6 @@ "@types/react-window": "^1.8.8", "eslint": "^8", "eslint-config-next": "15.0.3", - "libavoid-js": "^0.4.5", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" From 8760bd948ec0d31c0a1872b4257762c969d40b51 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 13:21:59 +0200 Subject: [PATCH 23/45] fix: simple entity attrbiute management not working bugfix --- Website/components/diagram/DiagramRenderer.ts | 87 +++++++++ Website/components/diagram/DiagramView.tsx | 9 +- .../diagram/entity/SimpleEntityElement.ts | 2 +- .../renderers/DetailedDiagramRender.ts | 2 +- .../diagram/renderers/SimpleDiagramRender.ts | 16 +- Website/hooks/useDiagram.ts | 175 +++++++----------- Website/lib/Types.ts | 2 +- 7 files changed, 176 insertions(+), 117 deletions(-) diff --git a/Website/components/diagram/DiagramRenderer.ts b/Website/components/diagram/DiagramRenderer.ts index ce03e19..5168d04 100644 --- a/Website/components/diagram/DiagramRenderer.ts +++ b/Website/components/diagram/DiagramRenderer.ts @@ -37,4 +37,91 @@ export abstract class DiagramRenderer { abstract onLinkClick(linkView: dia.LinkView, evt: dia.Event): void; abstract getVisibleAttributes(entity: EntityType): AttributeType[]; + + // 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 any; + if (entityElementTyped.updateAttributes) { + entityElementTyped.updateAttributes(updatedEntity); + } + } else if (entityElement.get('type') === 'delegate.simple-entity') { + // For simple entities, use updateEntity + const simpleEntityElementTyped = entityElement as any; + 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 EntityElement = require('@/components/diagram/entity/EntityElement').EntityElement; + 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) + entityMap.forEach((entityInfo, schemaName) => { + const entityData = entityInfo.element.get('data')?.entity; + if (entityData) { + this.createLinks(entityData, entityMap); + } + }); + } } \ No newline at end of file diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index c9ca24b..8021e0e 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -491,13 +491,13 @@ const DiagramContent = () => { }, [paper, selectedSquare]); const handleAddAttribute = (attribute: AttributeType) => { - if (!selectedEntityForActions) return; - addAttributeToEntity(selectedEntityForActions, attribute); + if (!selectedEntityForActions || !renderer) return; + addAttributeToEntity(selectedEntityForActions, attribute, renderer); }; const handleRemoveAttribute = (attribute: AttributeType) => { - if (!selectedEntityForActions) return; - removeAttributeFromEntity(selectedEntityForActions, attribute); + if (!selectedEntityForActions || !renderer) return; + removeAttributeFromEntity(selectedEntityForActions, attribute, renderer); }; const handleDeleteEntity = () => { @@ -523,7 +523,6 @@ const DiagramContent = () => { // Find the entity display name for the modal const selectedEntity = currentEntities.find(entity => entity.SchemaName === selectedEntityForActions); - const selectedEntityName = selectedEntity?.DisplayName; // Get available and visible attributes for the selected entity const availableAttributes = selectedEntity?.Attributes || []; diff --git a/Website/components/diagram/entity/SimpleEntityElement.ts b/Website/components/diagram/entity/SimpleEntityElement.ts index 9a9beec..541e2c6 100644 --- a/Website/components/diagram/entity/SimpleEntityElement.ts +++ b/Website/components/diagram/entity/SimpleEntityElement.ts @@ -126,7 +126,7 @@ export class SimpleEntityElement extends dia.Element { defaults() { return { - type: 'delegate.simple-entity', + type: 'delegate.entity', size: { width: 200, height: 80 }, attrs: { body: { diff --git a/Website/components/diagram/renderers/DetailedDiagramRender.ts b/Website/components/diagram/renderers/DetailedDiagramRender.ts index 5433e4d..22ccbd0 100644 --- a/Website/components/diagram/renderers/DetailedDiagramRender.ts +++ b/Website/components/diagram/renderers/DetailedDiagramRender.ts @@ -36,7 +36,7 @@ export class DetailedDiagramRender extends DiagramRenderer { if (!entityInfo) return; const { portMap } = entityInfo; - const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); + const visibleItems = this.getVisibleAttributes(entity); for (let i = 1; i < visibleItems.length; i++) { const attr = visibleItems[i]; diff --git a/Website/components/diagram/renderers/SimpleDiagramRender.ts b/Website/components/diagram/renderers/SimpleDiagramRender.ts index 51c328f..c91cc55 100644 --- a/Website/components/diagram/renderers/SimpleDiagramRender.ts +++ b/Website/components/diagram/renderers/SimpleDiagramRender.ts @@ -32,7 +32,10 @@ export class SimpleDiagramRenderer extends DiagramRenderer { const entityInfo = entityMap.get(entity.SchemaName); if (!entityInfo) return; - for (const attr of entity.Attributes) { + // 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) { @@ -86,7 +89,7 @@ export class SimpleDiagramRenderer extends DiagramRenderer { if (!entity) return; const entityId = graph.getElements().find(el => - el.get('type') === 'delegate.simple-entity' && + el.get('type') === 'delegate.entity' && el.get('data')?.entity?.SchemaName === entity.SchemaName )?.id; @@ -111,6 +114,13 @@ export class SimpleDiagramRenderer extends DiagramRenderer { } getVisibleAttributes(entity: EntityType): AttributeType[] { - return entity.Attributes; + // 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/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 42ea760..b2c90dc 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -1,7 +1,6 @@ import { useRef, useState, useCallback, useEffect } from 'react'; import { dia, routers } from '@joint/core'; import { GroupType, EntityType, AttributeType } from '@/lib/Types'; -import { EntityElement } from '@/components/diagram/entity/EntityElement'; import { SquareElement } from '@/components/diagram/entity/SquareElement'; import { SquareElementView } from '@/components/diagram/entity/SquareElementView'; import { PRESET_COLORS } from '@/components/diagram/panes/SquarePropertiesPane'; @@ -39,8 +38,8 @@ export interface DiagramActions { selectGroup: (group: GroupType) => void; updateMousePosition: (position: { x: number; y: number } | null) => void; updatePanPosition: (position: { x: number; y: number }) => void; - addAttributeToEntity: (entitySchemaName: string, attribute: AttributeType) => void; - removeAttributeFromEntity: (entitySchemaName: string, attribute: AttributeType) => 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) => void; @@ -186,7 +185,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { setPanPosition(position); }, []); - const addAttributeToEntity = useCallback((entitySchemaName: string, attribute: AttributeType) => { + const addAttributeToEntity = useCallback((entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => { // Prevent double additions if (isAddingAttributeRef.current) { return; @@ -199,120 +198,84 @@ export const useDiagram = (): DiagramState & DiagramActions => { return; } - // Find the entity element in the graph - const allElements = graphRef.current.getElements(); - - const entityElement = allElements.find(el => - el.get('type') === 'delegate.entity' && - el.get('data')?.entity?.SchemaName === entitySchemaName - ); - - if (entityElement) { - // Update the entity's data to include the new attribute - const currentEntity = entityElement.get('data').entity; - - // Check if attribute already exists in the entity - const attributeExists = currentEntity.Attributes.some((attr: AttributeType) => - attr.SchemaName === attribute.SchemaName - ); - - // Get current visible attributes list - const currentVisibleAttributes = (currentEntity.visibleAttributeSchemaNames || []); + // 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; + }); - let updatedEntity; - if (attributeExists) { - // Attribute already exists, just add it to visible list if not already there - updatedEntity = { - ...currentEntity, - visibleAttributeSchemaNames: currentVisibleAttributes.includes(attribute.SchemaName) - ? currentVisibleAttributes - : [...currentVisibleAttributes, attribute.SchemaName] - }; - } else { - // Attribute doesn't exist, add it to entity and make it visible - updatedEntity = { - ...currentEntity, - Attributes: [...currentEntity.Attributes, attribute], - visibleAttributeSchemaNames: [...currentVisibleAttributes, attribute.SchemaName] - }; - } - - // Update the element's data - entityElement.set('data', { entity: updatedEntity }); - - // Trigger the updateAttributes method to re-render the entity - const entityElementTyped = entityElement as EntityElement; - if (entityElementTyped.updateAttributes) { - entityElementTyped.updateAttributes(updatedEntity); + // 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); + } } - - // Update the currentEntities state to reflect the change - setCurrentEntities(prev => { - const updated = prev.map(entity => - entity.SchemaName === entitySchemaName - ? { - ...entity, - Attributes: attributeExists ? entity.Attributes : [...entity.Attributes, attribute], - visibleAttributeSchemaNames: updatedEntity.visibleAttributeSchemaNames - } - : entity - ); - return updated; - }); - } + + return updated; + }); // Reset the flag isAddingAttributeRef.current = false; }, []); - const removeAttributeFromEntity = useCallback((entitySchemaName: string, attribute: AttributeType) => { + const removeAttributeFromEntity = useCallback((entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => { if (!graphRef.current) { return; } - // Find the entity element in the graph - const allElements = graphRef.current.getElements(); - - const entityElement = allElements.find(el => - el.get('type') === 'delegate.entity' && - el.get('data')?.entity?.SchemaName === entitySchemaName - ); - - if (entityElement) { - // Update the entity's data to remove the attribute from visible list - const currentEntity = entityElement.get('data').entity; - - // Remove from visible attributes list - const updatedVisibleAttributes = (currentEntity.visibleAttributeSchemaNames || []) - .filter((attrName: string) => attrName !== attribute.SchemaName); + // 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; + }); - const updatedEntity = { - ...currentEntity, - visibleAttributeSchemaNames: updatedVisibleAttributes - }; - - // Update the element's data - entityElement.set('data', { entity: updatedEntity }); - - // Trigger the updateAttributes method to re-render the entity - const entityElementTyped = entityElement as EntityElement; - if (entityElementTyped.updateAttributes) { - entityElementTyped.updateAttributes(updatedEntity); + // 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); + } } - - // Update the currentEntities state to reflect the change - setCurrentEntities(prev => { - const updated = prev.map(entity => - entity.SchemaName === entitySchemaName - ? { - ...entity, - visibleAttributeSchemaNames: updatedVisibleAttributes - } - : entity - ); - return updated; - }); - } + + return updated; + }); }, []); const updateDiagramType = useCallback((type: DiagramType) => { @@ -406,7 +369,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { // Find and remove the entity element from the graph const entityElement = graphRef.current.getElements().find(el => - (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') && + el.get('type') === 'delegate.entity' && el.get('data')?.entity?.SchemaName === entitySchemaName ); diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts index e6ad7d1..6c63ea1 100644 --- a/Website/lib/Types.ts +++ b/Website/lib/Types.ts @@ -26,7 +26,7 @@ export type EntityType = { SecurityRoles: SecurityRole[], Keys: Key[], IconBase64: string | null, - manuallyAddedAttributes?: string[], + visibleAttributeSchemaNames?: string[], } export const enum RequiredLevel { From dc4a6213207445a736502ddcefa904f2f4635660 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 13:26:38 +0200 Subject: [PATCH 24/45] chore: attribute selection mode added to "add group" --- .../diagram/panes/AddEntityPane.tsx | 133 ++++-------------- .../components/diagram/panes/AddGroupPane.tsx | 30 +++- .../diagram/panes/AttributeSelectionPanel.tsx | 97 +++++++++++++ Website/hooks/useAttributeSelection.ts | 81 +++++++++++ Website/hooks/useDiagram.ts | 30 ++-- 5 files changed, 256 insertions(+), 115 deletions(-) create mode 100644 Website/components/diagram/panes/AttributeSelectionPanel.tsx create mode 100644 Website/hooks/useAttributeSelection.ts diff --git a/Website/components/diagram/panes/AddEntityPane.tsx b/Website/components/diagram/panes/AddEntityPane.tsx index d979981..73b06be 100644 --- a/Website/components/diagram/panes/AddEntityPane.tsx +++ b/Website/components/diagram/panes/AddEntityPane.tsx @@ -3,13 +3,13 @@ import React, { useState, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Checkbox } from '@/components/ui/checkbox'; -import { Search, ChevronDown, ChevronRight, Settings } from 'lucide-react'; +import { Search } from 'lucide-react'; import { Groups } from '@/generated/Data'; import { EntityType, GroupType, AttributeType } from '@/lib/Types'; +import { useAttributeSelection } from '@/hooks/useAttributeSelection'; +import { AttributeSelectionPanel } from './AttributeSelectionPanel'; export interface AddEntityPaneProps { isOpen: boolean; @@ -18,7 +18,12 @@ export interface AddEntityPaneProps { currentEntities: EntityType[]; } -type AttributeSelectionMode = 'minimal' | 'custom-lookups' | 'all-lookups' | 'custom'; +export interface AddEntityPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onAddEntity: (entity: EntityType, selectedAttributes?: string[]) => void; + currentEntities: EntityType[]; +} export const AddEntityPane: React.FC = ({ isOpen, @@ -27,10 +32,19 @@ export const AddEntityPane: React.FC = ({ currentEntities }) => { const [searchTerm, setSearchTerm] = useState(''); - const [attributeMode, setAttributeMode] = useState('custom-lookups'); const [selectedEntity, setSelectedEntity] = useState(null); - const [customSelectedAttributes, setCustomSelectedAttributes] = useState([]); 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(() => { @@ -53,54 +67,24 @@ export const AddEntityPane: React.FC = ({ }, [searchTerm]); const handleAddEntity = (entity: EntityType) => { - let selectedAttributes: string[] = []; - - // Determine which attributes to include based on mode - switch (attributeMode) { - case 'minimal': - // Only primary key (handled by default in useDiagram) - selectedAttributes = []; - break; - case 'custom-lookups': - selectedAttributes = entity.Attributes - .filter(attr => attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute) - .map(attr => attr.SchemaName); - break; - case 'all-lookups': - selectedAttributes = entity.Attributes - .filter(attr => attr.AttributeType === "LookupAttribute") - .map(attr => attr.SchemaName); - break; - case 'custom': - selectedAttributes = customSelectedAttributes; - break; - } - + const selectedAttributes = getSelectedAttributes(entity); onAddEntity(entity, selectedAttributes); onOpenChange(false); setSelectedEntity(null); - setCustomSelectedAttributes([]); + resetCustomAttributes(); }; const handleEntityClick = (entity: EntityType) => { if (attributeMode === 'custom') { setSelectedEntity(entity); - // Initialize with current default (custom lookups) - const defaultSelected = entity.Attributes - .filter(attr => attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute) - .map(attr => attr.SchemaName); - setCustomSelectedAttributes(defaultSelected); + initializeCustomAttributes(entity); } else { handleAddEntity(entity); } }; const handleCustomAttributeToggle = (attributeSchemaName: string, checked: boolean) => { - if (checked) { - setCustomSelectedAttributes(prev => [...prev, attributeSchemaName]); - } else { - setCustomSelectedAttributes(prev => prev.filter(name => name !== attributeSchemaName)); - } + toggleCustomAttribute(attributeSchemaName, checked); }; return ( @@ -111,68 +95,13 @@ export const AddEntityPane: React.FC = ({
{/* Attribute Selection Options */} - - - - - -
- -
-
- setAttributeMode('minimal')} - className="w-4 h-4" - /> - -
-
- setAttributeMode('custom-lookups')} - className="w-4 h-4" - /> - -
-
- setAttributeMode('all-lookups')} - className="w-4 h-4" - /> - -
-
- setAttributeMode('custom')} - className="w-4 h-4" - /> - -
-
-
-
-
+ {/* Search Input */}
diff --git a/Website/components/diagram/panes/AddGroupPane.tsx b/Website/components/diagram/panes/AddGroupPane.tsx index e205d84..031c55d 100644 --- a/Website/components/diagram/panes/AddGroupPane.tsx +++ b/Website/components/diagram/panes/AddGroupPane.tsx @@ -7,11 +7,13 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh import { Search, Database } from 'lucide-react'; import { Groups } from '@/generated/Data'; import { EntityType, GroupType } from '@/lib/Types'; +import { useAttributeSelection } from '@/hooks/useAttributeSelection'; +import { AttributeSelectionPanel } from './AttributeSelectionPanel'; export interface AddGroupPaneProps { isOpen: boolean; onOpenChange: (open: boolean) => void; - onAddGroup: (group: GroupType) => void; + onAddGroup: (group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => void; currentEntities: EntityType[]; } @@ -22,6 +24,14 @@ export const AddGroupPane: React.FC = ({ 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(() => { @@ -36,7 +46,14 @@ export const AddGroupPane: React.FC = ({ }, [searchTerm]); const handleAddGroup = (group: GroupType) => { - onAddGroup(group); + // 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); }; @@ -56,6 +73,15 @@ export const AddGroupPane: React.FC = ({ Add Group to Diagram
+ {/* Attribute Selection Options */} + + {/* Search Input */}
diff --git a/Website/components/diagram/panes/AttributeSelectionPanel.tsx b/Website/components/diagram/panes/AttributeSelectionPanel.tsx new file mode 100644 index 0000000..d917981 --- /dev/null +++ b/Website/components/diagram/panes/AttributeSelectionPanel.tsx @@ -0,0 +1,97 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ChevronDown, ChevronRight, Settings } from 'lucide-react'; +import { AttributeSelectionMode } from '@/hooks/useAttributeSelection'; + +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 ( + + + + + +
+ +
+
+ setAttributeMode('minimal')} + className="w-4 h-4" + /> + +
+
+ setAttributeMode('custom-lookups')} + className="w-4 h-4" + /> + +
+
+ setAttributeMode('all-lookups')} + className="w-4 h-4" + /> + +
+
+ setAttributeMode('custom')} + className="w-4 h-4" + /> + +
+
+
+
+
+ ); +}; diff --git a/Website/hooks/useAttributeSelection.ts b/Website/hooks/useAttributeSelection.ts new file mode 100644 index 0000000..738c064 --- /dev/null +++ b/Website/hooks/useAttributeSelection.ts @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { AttributeType, EntityType } from '@/lib/Types'; + +export type AttributeSelectionMode = 'minimal' | 'custom-lookups' | 'all-lookups' | 'custom'; + +export interface AttributeSelectionConfig { + mode: AttributeSelectionMode; + customSelectedAttributes: string[]; +} + +export const useAttributeSelection = (initialMode: AttributeSelectionMode = 'custom-lookups') => { + const [attributeMode, setAttributeMode] = useState(initialMode); + const [customSelectedAttributes, setCustomSelectedAttributes] = useState([]); + + const getSelectedAttributes = (entity: EntityType): string[] => { + switch (attributeMode) { + case 'minimal': + // Only primary key (handled by default in useDiagram) + return []; + case 'custom-lookups': + return entity.Attributes + .filter(attr => attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute) + .map(attr => attr.SchemaName); + case 'all-lookups': + return entity.Attributes + .filter(attr => attr.AttributeType === "LookupAttribute") + .map(attr => attr.SchemaName); + case 'custom': + return customSelectedAttributes; + default: + return []; + } + }; + + const initializeCustomAttributes = (entity: EntityType) => { + // Initialize with current default (custom lookups) + const defaultSelected = entity.Attributes + .filter(attr => attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute) + .map(attr => attr.SchemaName); + setCustomSelectedAttributes(defaultSelected); + }; + + const toggleCustomAttribute = (attributeSchemaName: string, checked: boolean) => { + if (checked) { + setCustomSelectedAttributes(prev => [...prev, attributeSchemaName]); + } else { + setCustomSelectedAttributes(prev => prev.filter(name => name !== attributeSchemaName)); + } + }; + + const resetCustomAttributes = () => { + setCustomSelectedAttributes([]); + }; + + const getAttributeModeDescription = (mode: AttributeSelectionMode): string => { + switch (mode) { + case 'minimal': + return 'Primary key only'; + case 'custom-lookups': + return 'Custom lookup attributes'; + case 'all-lookups': + return 'All lookup attributes'; + case 'custom': + return 'Pick specific attributes'; + default: + return ''; + } + }; + + return { + attributeMode, + setAttributeMode, + customSelectedAttributes, + setCustomSelectedAttributes, + getSelectedAttributes, + initializeCustomAttributes, + toggleCustomAttribute, + resetCustomAttributes, + getAttributeModeDescription, + }; +}; diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index b2c90dc..8c6ca75 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -42,7 +42,7 @@ export interface DiagramActions { removeAttributeFromEntity: (entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => void; updateDiagramType: (type: DiagramType) => void; addEntityToDiagram: (entity: EntityType, selectedAttributes?: string[]) => void; - addGroupToDiagram: (group: GroupType) => void; + addGroupToDiagram: (group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => void; removeEntityFromDiagram: (entitySchemaName: string) => void; addSquareToDiagram: () => void; } @@ -321,7 +321,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { setCurrentEntities(updatedEntities); }, [currentEntities, diagramType, fitToScreen]); - const addGroupToDiagram = useCallback((group: GroupType) => { + const addGroupToDiagram = useCallback((group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => { if (!graphRef.current || !paperRef.current) { return; } @@ -335,17 +335,25 @@ export const useDiagram = (): DiagramState & DiagramActions => { return; // All entities from this group are already in diagram } - // Initialize new entities with default visible attributes + // Initialize new entities with provided or default visible attributes const entitiesWithVisibleAttributes = newEntities.map(entity => { - const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); - const customLookupAttributes = entity.Attributes.filter(attr => - attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute - ); + let initialVisibleAttributes: string[]; - const initialVisibleAttributes = [ - ...(primaryKey ? [primaryKey.SchemaName] : []), - ...customLookupAttributes.map(attr => attr.SchemaName) - ]; + 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, From 79ac55261b643f76e0eb088e32b0827094ecc019 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 13:39:58 +0200 Subject: [PATCH 25/45] fix: detailed attribute highlight working again --- Website/components/diagram/DiagramRenderer.ts | 19 +++++ Website/components/diagram/DiagramView.tsx | 29 +++++-- .../diagram/entity/EntityAttribute.ts | 11 ++- .../renderers/DetailedDiagramRender.ts | 85 ++++++++++++++----- 4 files changed, 113 insertions(+), 31 deletions(-) diff --git a/Website/components/diagram/DiagramRenderer.ts b/Website/components/diagram/DiagramRenderer.ts index 5168d04..38de94b 100644 --- a/Website/components/diagram/DiagramRenderer.ts +++ b/Website/components/diagram/DiagramRenderer.ts @@ -6,12 +6,15 @@ export type IPortMap = Record; export abstract class DiagramRenderer { protected graph: dia.Graph; protected setSelectedKey?: (key: string | undefined) => void; + private instanceId: string; + protected currentSelectedKey?: string; constructor( graph: dia.Graph | undefined | null, options?: { setSelectedKey?: (key: string | undefined) => 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; @@ -38,6 +41,22 @@ export abstract class DiagramRenderer { 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 diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 8021e0e..7bdad43 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useState, useCallback } from 'react' import { dia, shapes, util } from '@joint/core' import { Groups } from "../../generated/Data" import { EntityElement } from '@/components/diagram/entity/EntityElement'; @@ -46,6 +46,11 @@ const DiagramContent = () => { const [selectedKey, setSelectedKey] = useState(); const [selectedEntityForActions, setSelectedEntityForActions] = useState(); + + // Wrapper for setSelectedKey to pass to renderer + const handleSetSelectedKey = useCallback((key: string | undefined) => { + setSelectedKey(key); + }, []); const [isEntityActionsSheetOpen, setIsEntityActionsSheetOpen] = useState(false); const [selectedSquare, setSelectedSquare] = useState(null); const [isSquarePropertiesSheetOpen, setIsSquarePropertiesSheetOpen] = useState(false); @@ -73,9 +78,9 @@ const DiagramContent = () => { })(); return new RendererClass(graph, { - setSelectedKey + setSelectedKey: handleSetSelectedKey }); - }, [diagramType, graph, setSelectedKey]); + }, [diagramType, graph, handleSetSelectedKey]); useEffect(() => { if (Groups.length > 0 && !selectedGroup) selectGroup(Groups[0]); @@ -83,9 +88,12 @@ const DiagramContent = () => { useEffect(() => { if (!renderer) return; - document.addEventListener('click', renderer.onDocumentClick); + + // Bind the method to the renderer instance + const boundOnDocumentClick = renderer.onDocumentClick.bind(renderer); + document.addEventListener('click', boundOnDocumentClick); return () => { - document.removeEventListener('click', renderer.onDocumentClick); + document.removeEventListener('click', boundOnDocumentClick); }; }, [renderer]); @@ -162,8 +170,12 @@ const DiagramContent = () => { }, [graph, paper, selectedGroup, currentEntities, diagramType]); useEffect(() => { - if (!selectedKey || !graph || !renderer) return; + 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); @@ -172,7 +184,10 @@ const DiagramContent = () => { link.attr('line/sourceMarker/stroke', '#42a5f5'); }); - renderer.highlightSelectedKey(graph, currentEntities, selectedKey); + // Only highlight if there's a selected key + if (selectedKey) { + renderer.highlightSelectedKey(graph, currentEntities, selectedKey); + } }, [selectedKey, graph, currentEntities, renderer]); useEffect(() => { diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/entity/EntityAttribute.ts index c91d8c0..ae8bdae 100644 --- a/Website/components/diagram/entity/EntityAttribute.ts +++ b/Website/components/diagram/entity/EntityAttribute.ts @@ -24,15 +24,22 @@ export const EntityAttribute = ({ attribute, isKey, isSelected = false }: IEntit icon = `` } - const buttonClasses = `w-full rounded-sm my-[4px] p-[4px] flex items-center h-[28px] ${isKey ? 'transition-colors duration-300 hover:bg-blue-200 cursor-pointer' : ''}`; + 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 ` + +
+

+ Optional text to display on the link +

+
+ + + + {/* Color Section */} +
+ +
+ {PRESET_COLORS.borders.map((presetColor) => ( + + ))} +
+ +
+ handleColorChange(e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleColorChange(e.target.value)} + placeholder="#3b82f6" + className="flex-1 text-sm" + /> +
+
+ + + + {/* Line Style Section */} +
+ + +
+ + {/* Stroke Width Section */} +
+ + +
+ + +
+ + + ); +}; diff --git a/Website/components/diagram/panes/SquarePropertiesPane.tsx b/Website/components/diagram/panes/SquarePropertiesPane.tsx index 4e531a4..72b7777 100644 --- a/Website/components/diagram/panes/SquarePropertiesPane.tsx +++ b/Website/components/diagram/panes/SquarePropertiesPane.tsx @@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Separator } from '@/components/ui/separator'; import { Palette, Square, Trash2 } from 'lucide-react'; import { SquareElement, SquareElementData } from '../entity/SquareElement'; +import { PRESET_COLORS } from '../shared/DiagramConstants'; export interface SquarePropertiesPaneProps { isOpen: boolean; @@ -15,23 +16,6 @@ export interface SquarePropertiesPaneProps { onDeleteSquare?: () => void; } -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 SquarePropertiesPane: React.FC = ({ isOpen, onOpenChange, @@ -103,13 +87,17 @@ export const SquarePropertiesPane: React.FC = ({ {/* Fill Color Section */}
-
+
{PRESET_COLORS.fills.map((color) => ( - diff --git a/Website/components/diagram/entity/TextElement.ts b/Website/components/diagram/entity/TextElement.ts new file mode 100644 index 0000000..38ad7f7 --- /dev/null +++ b/Website/components/diagram/entity/TextElement.ts @@ -0,0 +1,132 @@ +import { dia, shapes, util } from '@joint/core'; + +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?: any, options?: any) { + 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) { + // Calculate approximate text width (rough estimation) + const charWidth = data.fontSize * 0.6; // Approximate character width + 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 any).delegate = { + ...(shapes as any).delegate, + text: TextElement +}; diff --git a/Website/components/diagram/panes/TextPropertiesPane.tsx b/Website/components/diagram/panes/TextPropertiesPane.tsx new file mode 100644 index 0000000..124df11 --- /dev/null +++ b/Website/components/diagram/panes/TextPropertiesPane.tsx @@ -0,0 +1,279 @@ +import React, { useState, useEffect } from 'react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { Type, Trash2 } from 'lucide-react'; +import { TextElement, TextElementData } from '../entity/TextElement'; + +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: '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: any) => { + setTextData(prev => ({ ...prev, [key]: value })); + }; + + const handleDeleteText = () => { + if (selectedText && onDeleteText) { + onDeleteText(); + onOpenChange(false); + } + }; + + if (!selectedText) { + return null; + } + + return ( + + + + + + Text Properties + + + +
+ {/* Text Content */} +
+ + ) => handleDataChange('text', e.target.value)} + /> +
+ + + + {/* Typography */} +
+ + + {/* Font Family */} +
+ + +
+ + {/* Font Size */} +
+ + +
+ + {/* Text Alignment */} +
+ + +
+
+ + + + {/* Colors */} +
+ + + {/* Text Color */} +
+ +
+ handleDataChange('color', e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleDataChange('color', e.target.value)} + placeholder="#000000" + className="flex-1 text-sm" + /> +
+
+ + {/* Background Color */} +
+ +
+ handleDataChange('backgroundColor', e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleDataChange('backgroundColor', e.target.value)} + placeholder="transparent" + className="flex-1 text-sm" + /> +
+
+
+ + + + {/* Layout */} +
+ + + {/* Padding */} +
+ + handleDataChange('padding', parseInt(e.target.value) || 0)} + className="text-sm" + /> +
+ + {/* Border Radius */} +
+ + handleDataChange('borderRadius', parseInt(e.target.value) || 0)} + className="text-sm" + /> +
+
+ + + + {/* Delete Section */} + {onDeleteText && ( +
+ + +
+ )} +
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/index.ts b/Website/components/diagram/panes/index.ts index 529fcff..eb3045e 100644 --- a/Website/components/diagram/panes/index.ts +++ b/Website/components/diagram/panes/index.ts @@ -3,9 +3,11 @@ export { AddGroupPane } from './AddGroupPane'; export { EntityActionsPane } from './EntityActionsPane'; export { SquarePropertiesPane } from './SquarePropertiesPane'; export { LinkPropertiesPane } from './LinkPropertiesPane'; +export { TextPropertiesPane } from './TextPropertiesPane'; 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/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 8c6ca75..719fd9a 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -3,11 +3,12 @@ import { dia, routers } from '@joint/core'; import { GroupType, EntityType, AttributeType } from '@/lib/Types'; import { SquareElement } from '@/components/diagram/entity/SquareElement'; import { SquareElementView } from '@/components/diagram/entity/SquareElementView'; -import { PRESET_COLORS } from '@/components/diagram/panes/SquarePropertiesPane'; +import { TextElement } from '@/components/diagram/entity/TextElement'; import { AvoidRouter } from '@/components/diagram/avoid-router/avoidrouter'; import { DiagramRenderer } from '@/components/diagram/DiagramRenderer'; import { SimpleDiagramRenderer } from '@/components/diagram/renderers/SimpleDiagramRender'; import { DetailedDiagramRender } from '@/components/diagram/renderers/DetailedDiagramRender'; +import { PRESET_COLORS } from '@/components/diagram/shared/DiagramConstants'; export type DiagramType = 'detailed' | 'simple'; @@ -45,6 +46,7 @@ export interface DiagramActions { addGroupToDiagram: (group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => void; removeEntityFromDiagram: (entitySchemaName: string) => void; addSquareToDiagram: () => void; + addTextToDiagram: () => void; } export const useDiagram = (): DiagramState & DiagramActions => { @@ -436,6 +438,69 @@ export const useDiagram = (): DiagramState & DiagramActions => { 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: 'Inter', + 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: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", 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 initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { // Create graph if it doesn't exist if (!graphRef.current) { @@ -642,5 +707,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { addGroupToDiagram, removeEntityFromDiagram, addSquareToDiagram, + addTextToDiagram, }; }; \ No newline at end of file From 0e8a082f80e6a6cb55a8a582b4aea22025d66349 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 16:43:32 +0200 Subject: [PATCH 29/45] feat: load/save of diagrams using JSON format --- Website/components/diagram/DiagramView.tsx | 18 ++- .../components/diagram/SidebarDiagramView.tsx | 53 ++++++- .../diagram/avoid-router/avoidrouter.ts | 9 +- Website/hooks/useDiagram.ts | 144 +++++++++++++++++- 4 files changed, 215 insertions(+), 9 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 9ac885d..c88bbaf 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -113,14 +113,21 @@ const DiagramContent = () => { useEffect(() => { if (!graph || !paper || !selectedGroup || !renderer) return; - // Preserve squares before clearing - only clear entities and links + // Preserve squares and text elements before clearing - only clear entities and links const squares = graph.getElements().filter(element => element.get('type') === 'delegate.square'); + const textElements = graph.getElements().filter(element => element.get('type') === 'delegate.text'); 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() + })); // Clear existing elements graph.clear(); @@ -134,6 +141,15 @@ const DiagramContent = () => { 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(); diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx index 1f44e97..9d40bb8 100644 --- a/Website/components/diagram/SidebarDiagramView.tsx +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Button } from '@/components/ui/button'; -import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer, Users } from 'lucide-react'; +import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer, Users, Save, Upload } from 'lucide-react'; import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; import { AddEntityPane, AddGroupPane } from '@/components/diagram/panes'; import { DiagramType } from '@/hooks/useDiagram'; @@ -12,12 +12,23 @@ interface ISidebarDiagramViewProps { } export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { - const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, currentEntities, diagramType, updateDiagramType } = useDiagramViewContext(); + const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType } = useDiagramViewContext(); const [isDataExpanded, setIsDataExpanded] = useState(true); const [isGeneralExpanded, setIsGeneralExpanded] = useState(false); const [isEntitySheetOpen, setIsEntitySheetOpen] = useState(false); const [isGroupSheetOpen, setIsGroupSheetOpen] = useState(false); + 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 = ''; + }; + return (
@@ -25,9 +36,6 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { - - - @@ -124,6 +132,41 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => {
+
+
+

Save & Load

+

+ Save your diagram or load an existing one +

+
+ + +
+
+
+

Current Settings

diff --git a/Website/components/diagram/avoid-router/avoidrouter.ts b/Website/components/diagram/avoid-router/avoidrouter.ts index e75eb39..38e6087 100644 --- a/Website/components/diagram/avoid-router/avoidrouter.ts +++ b/Website/components/diagram/avoid-router/avoidrouter.ts @@ -97,8 +97,8 @@ export class AvoidRouter { } updateShape(element: dia.Element): void { - // Skip squares - they shouldn't be obstacles for routing - if (element.get('type') === 'delegate.square') { + // Skip squares and text elements - they shouldn't be obstacles for routing + if (element.get('type') === 'delegate.square' || element.get('type') === 'delegate.text') { return; } @@ -198,6 +198,11 @@ export class AvoidRouter { } deleteShape(element: dia.Element): void { + // Skip squares and text elements - they weren't added as obstacles + if (element.get('type') === 'delegate.square' || element.get('type') === 'delegate.text') { + return; + } + const shapeRef = this.shapeRefs[element.id]; if (!shapeRef) return; this.avoidRouter.deleteShape(shapeRef); diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 719fd9a..75e0ea4 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -1,5 +1,5 @@ import { useRef, useState, useCallback, useEffect } from 'react'; -import { dia, routers } from '@joint/core'; +import { dia, routers, shapes } from '@joint/core'; import { GroupType, EntityType, AttributeType } from '@/lib/Types'; import { SquareElement } from '@/components/diagram/entity/SquareElement'; import { SquareElementView } from '@/components/diagram/entity/SquareElementView'; @@ -47,6 +47,8 @@ export interface DiagramActions { removeEntityFromDiagram: (entitySchemaName: string) => void; addSquareToDiagram: () => void; addTextToDiagram: () => void; + saveDiagram: () => void; + loadDiagram: (file: File) => Promise; } export const useDiagram = (): DiagramState & DiagramActions => { @@ -501,6 +503,144 @@ export const useDiagram = (): DiagramState & DiagramActions => { 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); + + console.log('Parsed diagram data:', diagramData); + + 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) { + console.log('Loading graph data:', 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') { + console.log('Creating SquareElement:', cellData); + cell = new SquareElement({ + id: cellData.id, + position: cellData.position, + size: cellData.size, + attrs: cellData.attrs, + data: cellData.data + }); + } else if (cellData.type === 'delegate.text') { + console.log('Creating TextElement:', cellData); + cell = new TextElement({ + id: cellData.id, + position: cellData.position, + size: cellData.size, + attrs: cellData.attrs, + data: cellData.data + }); + } else { + console.log('Creating standard element:', cellData.type); + // Use fromJSON for standard elements + try { + cell = dia.Cell.fromJSON(cellData); + } catch (error) { + console.warn('Failed to create cell:', cellData.type, error); + return; + } + } + + if (cell) { + graphRef.current!.addCell(cell); + } + }); + + console.log('Graph loaded successfully'); + } else { + console.warn('No graph data found in diagram file'); + } + + // Restore diagram type + if (diagramData.diagramType) { + console.log('Restoring diagram type:', diagramData.diagramType); + setDiagramType(diagramData.diagramType); + } + + // Restore entities + if (diagramData.currentEntities) { + console.log('Restoring entities:', diagramData.currentEntities.length); + setCurrentEntities(diagramData.currentEntities); + } + + // Restore view settings + if (diagramData.viewState) { + const { panPosition: savedPanPosition, zoom: savedZoom } = diagramData.viewState; + + if (savedZoom && paperRef.current) { + console.log('Restoring zoom:', savedZoom); + paperRef.current.scale(savedZoom, savedZoom); + updateZoomDisplay(savedZoom); + } + + if (savedPanPosition && paperRef.current) { + console.log('Restoring pan position:', savedPanPosition); + paperRef.current.translate(savedPanPosition.x, savedPanPosition.y); + setPanPosition(savedPanPosition); + } + } + + console.log('Diagram loaded successfully'); + } 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 initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { // Create graph if it doesn't exist if (!graphRef.current) { @@ -708,5 +848,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { removeEntityFromDiagram, addSquareToDiagram, addTextToDiagram, + saveDiagram, + loadDiagram, }; }; \ No newline at end of file From ddaf306e73c2e33ba6d7b9b5dc79f3495b71d474 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 16:59:41 +0200 Subject: [PATCH 30/45] chore: mobile disclaimer --- .../components/diagram/SidebarDiagramView.tsx | 20 ++++++++++++++++++- Website/hooks/useDiagram.ts | 1 - 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx index 9d40bb8..fd0a85f 100644 --- a/Website/components/diagram/SidebarDiagramView.tsx +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -2,10 +2,11 @@ import React, { useState, useMemo } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Button } from '@/components/ui/button'; -import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer, Users, Save, Upload } from 'lucide-react'; +import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer, Users, Save, Upload, Smartphone } from 'lucide-react'; import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; import { AddEntityPane, AddGroupPane } from '@/components/diagram/panes'; import { DiagramType } from '@/hooks/useDiagram'; +import { useIsMobile } from '@/hooks/use-mobile'; interface ISidebarDiagramViewProps { @@ -13,6 +14,7 @@ interface ISidebarDiagramViewProps { export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType } = useDiagramViewContext(); + const isMobile = useIsMobile(); const [isDataExpanded, setIsDataExpanded] = useState(true); const [isGeneralExpanded, setIsGeneralExpanded] = useState(false); const [isEntitySheetOpen, setIsEntitySheetOpen] = useState(false); @@ -42,6 +44,22 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { + {/* 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 */} diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 75e0ea4..1d577fc 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -584,7 +584,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { data: cellData.data }); } else { - console.log('Creating standard element:', cellData.type); // Use fromJSON for standard elements try { cell = dia.Cell.fromJSON(cellData); From 1b824136c6630ab5cdaa9e288470fdd69235411c Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 17:04:13 +0200 Subject: [PATCH 31/45] fix: fixed bug when navigating from diagram to datamodel --- .../components/diagram/SidebarDiagramView.tsx | 18 ++++++++++++++++-- Website/contexts/DiagramViewContext.tsx | 5 +++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx index fd0a85f..a6ed68b 100644 --- a/Website/components/diagram/SidebarDiagramView.tsx +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -3,7 +3,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Button } from '@/components/ui/button'; import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer, Users, Save, Upload, Smartphone } from 'lucide-react'; -import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; +import { useDiagramViewContextSafe } from '@/contexts/DiagramViewContext'; import { AddEntityPane, AddGroupPane } from '@/components/diagram/panes'; import { DiagramType } from '@/hooks/useDiagram'; import { useIsMobile } from '@/hooks/use-mobile'; @@ -13,13 +13,27 @@ interface ISidebarDiagramViewProps { } export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { - const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType } = useDiagramViewContext(); + 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); + // If not in diagram context, show a message or return null + if (!diagramContext) { + return ( +
+
+ +

Diagram tools are only available on the diagram page.

+
+
+ ); + } + + const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType } = diagramContext; + const handleLoadDiagram = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx index c79e6d3..1b0d4e0 100644 --- a/Website/contexts/DiagramViewContext.tsx +++ b/Website/contexts/DiagramViewContext.tsx @@ -25,4 +25,9 @@ export const useDiagramViewContext = (): DiagramViewContextType => { 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 From 43b9635e152ab184a4e948230a5548084fbdd17b Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 17:18:55 +0200 Subject: [PATCH 32/45] chore: added reset functionality to reset to a single group and clear entire diagram. --- Website/components/diagram/DiagramView.tsx | 8 +- .../components/diagram/GridLayoutManager.ts | 2 +- .../components/diagram/SidebarDiagramView.tsx | 54 ++++++++- .../{entity => elements}/EntityAttribute.ts | 0 .../{entity => elements}/EntityBody.ts | 0 .../{entity => elements}/EntityElement.ts | 0 .../SimpleEntityElement.ts | 0 .../{entity => elements}/SquareElement.ts | 0 .../{entity => elements}/SquareElementView.ts | 0 .../{entity => elements}/TextElement.ts | 0 .../diagram/panes/ResetToGroupPane.tsx | 108 ++++++++++++++++++ .../diagram/panes/SquarePropertiesPane.tsx | 2 +- .../diagram/panes/TextPropertiesPane.tsx | 2 +- Website/components/diagram/panes/index.ts | 1 + .../renderers/DetailedDiagramRender.ts | 4 +- .../diagram/renderers/SimpleDiagramRender.ts | 4 +- Website/hooks/useDiagram.ts | 26 ++++- 17 files changed, 194 insertions(+), 17 deletions(-) rename Website/components/diagram/{entity => elements}/EntityAttribute.ts (100%) rename Website/components/diagram/{entity => elements}/EntityBody.ts (100%) rename Website/components/diagram/{entity => elements}/EntityElement.ts (100%) rename Website/components/diagram/{entity => elements}/SimpleEntityElement.ts (100%) rename Website/components/diagram/{entity => elements}/SquareElement.ts (100%) rename Website/components/diagram/{entity => elements}/SquareElementView.ts (100%) rename Website/components/diagram/{entity => elements}/TextElement.ts (100%) create mode 100644 Website/components/diagram/panes/ResetToGroupPane.tsx diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index c88bbaf..b6e804b 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -3,10 +3,10 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react' import { dia, shapes, util } from '@joint/core' import { Groups } from "../../generated/Data" -import { EntityElement } from '@/components/diagram/entity/EntityElement'; -import { SimpleEntityElement } from '@/components/diagram/entity/SimpleEntityElement'; -import { SquareElement } from '@/components/diagram/entity/SquareElement'; -import { TextElement } from '@/components/diagram/entity/TextElement'; +import { EntityElement } from '@/components/diagram/elements/EntityElement'; +import { SimpleEntityElement } from '@/components/diagram/elements/SimpleEntityElement'; +import { SquareElement } from '@/components/diagram/elements/SquareElement'; +import { TextElement } from '@/components/diagram/elements/TextElement'; import debounce from 'lodash/debounce'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts index 673c9be..6450880 100644 --- a/Website/components/diagram/GridLayoutManager.ts +++ b/Website/components/diagram/GridLayoutManager.ts @@ -1,5 +1,5 @@ import { EntityType } from '@/lib/Types'; -import { EntityElement } from '@/components/diagram/entity/EntityElement'; +import { EntityElement } from '@/components/diagram/elements/EntityElement'; export interface GridLayoutOptions { containerWidth: number; diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx index a6ed68b..79539bc 100644 --- a/Website/components/diagram/SidebarDiagramView.tsx +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -2,11 +2,12 @@ import React, { useState, useMemo } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Button } from '@/components/ui/button'; -import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer, Users, Save, Upload, Smartphone } from 'lucide-react'; +import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer, Users, Save, Upload, Smartphone, RotateCcw, Trash2 } from 'lucide-react'; import { useDiagramViewContextSafe } from '@/contexts/DiagramViewContext'; -import { AddEntityPane, AddGroupPane } from '@/components/diagram/panes'; +import { AddEntityPane, AddGroupPane, ResetToGroupPane } from '@/components/diagram/panes'; import { DiagramType } from '@/hooks/useDiagram'; import { useIsMobile } from '@/hooks/use-mobile'; +import { Groups } from '../../generated/Data'; interface ISidebarDiagramViewProps { @@ -19,6 +20,7 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { const [isGeneralExpanded, setIsGeneralExpanded] = useState(false); const [isEntitySheetOpen, setIsEntitySheetOpen] = useState(false); const [isGroupSheetOpen, setIsGroupSheetOpen] = useState(false); + const [isResetSheetOpen, setIsResetSheetOpen] = useState(false); // If not in diagram context, show a message or return null if (!diagramContext) { @@ -32,7 +34,7 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { ); } - const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType } = diagramContext; + const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType, removeEntityFromDiagram, clearDiagram } = diagramContext; const handleLoadDiagram = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -45,6 +47,16 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { event.target.value = ''; }; + const handleResetToGroup = (group: any) => { + // First clear the entire diagram + clearDiagram(); + // Then add the selected group + addGroupToDiagram(group); + }; + + // Use the clearDiagram function from the hook + // const clearDiagram function is already available from the context + return (
@@ -208,6 +220,35 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => {
+ +
+
+

Diagram Actions

+

+ Reset or clear your diagram +

+
+ + +
+
+
@@ -227,6 +268,13 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { onAddGroup={addGroupToDiagram} currentEntities={currentEntities} /> + + {/* Reset to Group Pane */} +
); } \ No newline at end of file diff --git a/Website/components/diagram/entity/EntityAttribute.ts b/Website/components/diagram/elements/EntityAttribute.ts similarity index 100% rename from Website/components/diagram/entity/EntityAttribute.ts rename to Website/components/diagram/elements/EntityAttribute.ts diff --git a/Website/components/diagram/entity/EntityBody.ts b/Website/components/diagram/elements/EntityBody.ts similarity index 100% rename from Website/components/diagram/entity/EntityBody.ts rename to Website/components/diagram/elements/EntityBody.ts diff --git a/Website/components/diagram/entity/EntityElement.ts b/Website/components/diagram/elements/EntityElement.ts similarity index 100% rename from Website/components/diagram/entity/EntityElement.ts rename to Website/components/diagram/elements/EntityElement.ts diff --git a/Website/components/diagram/entity/SimpleEntityElement.ts b/Website/components/diagram/elements/SimpleEntityElement.ts similarity index 100% rename from Website/components/diagram/entity/SimpleEntityElement.ts rename to Website/components/diagram/elements/SimpleEntityElement.ts diff --git a/Website/components/diagram/entity/SquareElement.ts b/Website/components/diagram/elements/SquareElement.ts similarity index 100% rename from Website/components/diagram/entity/SquareElement.ts rename to Website/components/diagram/elements/SquareElement.ts diff --git a/Website/components/diagram/entity/SquareElementView.ts b/Website/components/diagram/elements/SquareElementView.ts similarity index 100% rename from Website/components/diagram/entity/SquareElementView.ts rename to Website/components/diagram/elements/SquareElementView.ts diff --git a/Website/components/diagram/entity/TextElement.ts b/Website/components/diagram/elements/TextElement.ts similarity index 100% rename from Website/components/diagram/entity/TextElement.ts rename to Website/components/diagram/elements/TextElement.ts diff --git a/Website/components/diagram/panes/ResetToGroupPane.tsx b/Website/components/diagram/panes/ResetToGroupPane.tsx new file mode 100644 index 0000000..968a00d --- /dev/null +++ b/Website/components/diagram/panes/ResetToGroupPane.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Button } from '@/components/ui/button'; +import { RotateCcw } from 'lucide-react'; +import { Groups } from '../../../generated/Data'; +import { GroupType } from '@/lib/Types'; + +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 ( + + + + 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. + + + +
+
+ + +
+ +
+
+
+

Warning

+

This will clear all current elements from your diagram and replace them with entities from the selected group.

+
+
+
+ +
+ + +
+
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/SquarePropertiesPane.tsx b/Website/components/diagram/panes/SquarePropertiesPane.tsx index 72b7777..fc3c90f 100644 --- a/Website/components/diagram/panes/SquarePropertiesPane.tsx +++ b/Website/components/diagram/panes/SquarePropertiesPane.tsx @@ -6,7 +6,7 @@ import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { Palette, Square, Trash2 } from 'lucide-react'; -import { SquareElement, SquareElementData } from '../entity/SquareElement'; +import { SquareElement, SquareElementData } from '../elements/SquareElement'; import { PRESET_COLORS } from '../shared/DiagramConstants'; export interface SquarePropertiesPaneProps { diff --git a/Website/components/diagram/panes/TextPropertiesPane.tsx b/Website/components/diagram/panes/TextPropertiesPane.tsx index 124df11..8dc2223 100644 --- a/Website/components/diagram/panes/TextPropertiesPane.tsx +++ b/Website/components/diagram/panes/TextPropertiesPane.tsx @@ -6,7 +6,7 @@ import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { Type, Trash2 } from 'lucide-react'; -import { TextElement, TextElementData } from '../entity/TextElement'; +import { TextElement, TextElementData } from '../elements/TextElement'; export interface TextPropertiesPaneProps { isOpen: boolean; diff --git a/Website/components/diagram/panes/index.ts b/Website/components/diagram/panes/index.ts index eb3045e..ff2ccf1 100644 --- a/Website/components/diagram/panes/index.ts +++ b/Website/components/diagram/panes/index.ts @@ -4,6 +4,7 @@ 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'; diff --git a/Website/components/diagram/renderers/DetailedDiagramRender.ts b/Website/components/diagram/renderers/DetailedDiagramRender.ts index 8084719..9c21b0c 100644 --- a/Website/components/diagram/renderers/DetailedDiagramRender.ts +++ b/Website/components/diagram/renderers/DetailedDiagramRender.ts @@ -1,8 +1,8 @@ // DetailedDiagramRender.ts import { dia, shapes } from '@joint/core'; -import { SimpleEntityElement } from '@/components/diagram/entity/SimpleEntityElement'; +import { SimpleEntityElement } from '@/components/diagram/elements/SimpleEntityElement'; import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; -import { EntityElement } from '../entity/EntityElement'; +import { EntityElement } from '../elements/EntityElement'; import { AttributeType, EntityType } from '@/lib/Types'; export class DetailedDiagramRender extends DiagramRenderer { diff --git a/Website/components/diagram/renderers/SimpleDiagramRender.ts b/Website/components/diagram/renderers/SimpleDiagramRender.ts index 71f48b3..a70a38c 100644 --- a/Website/components/diagram/renderers/SimpleDiagramRender.ts +++ b/Website/components/diagram/renderers/SimpleDiagramRender.ts @@ -1,8 +1,8 @@ // SimpleDiagramRenderer.ts import { dia, shapes } from '@joint/core'; -import { SimpleEntityElement } from '@/components/diagram/entity/SimpleEntityElement'; +import { SimpleEntityElement } from '@/components/diagram/elements/SimpleEntityElement'; import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; -import { EntityElement } from '../entity/EntityElement'; +import { EntityElement } from '../elements/EntityElement'; import { AttributeType, EntityType } from '@/lib/Types'; export class SimpleDiagramRenderer extends DiagramRenderer { diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 1d577fc..5d961a1 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -1,9 +1,9 @@ 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/diagram/entity/SquareElement'; -import { SquareElementView } from '@/components/diagram/entity/SquareElementView'; -import { TextElement } from '@/components/diagram/entity/TextElement'; +import { SquareElement } from '@/components/diagram/elements/SquareElement'; +import { SquareElementView } from '@/components/diagram/elements/SquareElementView'; +import { TextElement } from '@/components/diagram/elements/TextElement'; import { AvoidRouter } from '@/components/diagram/avoid-router/avoidrouter'; import { DiagramRenderer } from '@/components/diagram/DiagramRenderer'; import { SimpleDiagramRenderer } from '@/components/diagram/renderers/SimpleDiagramRender'; @@ -49,6 +49,7 @@ export interface DiagramActions { addTextToDiagram: () => void; saveDiagram: () => void; loadDiagram: (file: File) => Promise; + clearDiagram: () => void; } export const useDiagram = (): DiagramState & DiagramActions => { @@ -640,6 +641,24 @@ export const useDiagram = (): DiagramState & DiagramActions => { } }, [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(); + + console.log('Diagram cleared successfully'); + }, [graphRef, clearSelection, setCurrentEntities]); + const initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { // Create graph if it doesn't exist if (!graphRef.current) { @@ -849,5 +868,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { addTextToDiagram, saveDiagram, loadDiagram, + clearDiagram, }; }; \ No newline at end of file From 9189e9b65bace5841ddf280d6183a1270f5b8e0c Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 17:23:31 +0200 Subject: [PATCH 33/45] chore: added new icon to the diagram nav --- Website/components/SidebarNavRail.tsx | 44 ++++++++++++++++++--------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/Website/components/SidebarNavRail.tsx b/Website/components/SidebarNavRail.tsx index 8440396..01344bb 100644 --- a/Website/components/SidebarNavRail.tsx +++ b/Website/components/SidebarNavRail.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useRouter, usePathname } from "next/navigation"; -import { LogOut, Info, Database, PencilRuler } from "lucide-react"; +import { LogOut, Info, Database, PencilRuler, PlugZap, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useSidebarDispatch } from "@/contexts/SidebarContext"; @@ -11,6 +11,7 @@ const navItems = [ href: "/", active: true, disabled: false, + new: false, }, { label: "Diagram Viewer", @@ -18,6 +19,15 @@ const navItems = [ href: "/diagram", active: false, disabled: false, + new: true, + }, + { + label: "Dependency Viewer", + icon: , + href: "/dependency", + active: false, + disabled: true, + new: false, }, ]; @@ -35,19 +45,25 @@ export default function SidebarNavRail() { {/* Nav Items */}
{navItems.map((item) => ( - +
+ + {item.new && ( +
+ +
+ )} +
))}
From 1504f4e6cb64b810e6d241d1401515b89cb61477 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 17:39:58 +0200 Subject: [PATCH 34/45] chore: fixing linting errors and removeing old console.logs from earlier debugging --- Website/components/diagram/DiagramCanvas.tsx | 15 +--------- .../components/diagram/DiagramControls.tsx | 1 - Website/components/diagram/DiagramRenderer.ts | 10 +++---- Website/components/diagram/DiagramView.tsx | 28 +++++++------------ .../components/diagram/SidebarDiagramView.tsx | 11 ++++---- .../diagram/avoid-router/avoidrouter.ts | 3 ++ .../diagram/elements/EntityElement.ts | 10 +++---- .../diagram/elements/SimpleEntityElement.ts | 2 +- .../diagram/elements/SquareElement.ts | 4 +-- .../diagram/elements/TextElement.ts | 6 ++-- .../diagram/panes/EntityActionsPane.tsx | 1 - .../diagram/panes/LinkPropertiesPane.tsx | 27 ------------------ .../diagram/panes/SquarePropertiesPane.tsx | 4 +-- .../diagram/panes/TextPropertiesPane.tsx | 2 +- .../renderers/DetailedDiagramRender.ts | 3 +- .../diagram/renderers/SimpleDiagramRender.ts | 5 ++-- Website/hooks/useDiagram.ts | 14 ---------- 17 files changed, 41 insertions(+), 105 deletions(-) diff --git a/Website/components/diagram/DiagramCanvas.tsx b/Website/components/diagram/DiagramCanvas.tsx index b1b4d48..3fd0516 100644 --- a/Website/components/diagram/DiagramCanvas.tsx +++ b/Website/components/diagram/DiagramCanvas.tsx @@ -1,5 +1,5 @@ import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; -import React, { useRef, useEffect, useState } from 'react'; +import React, { useRef, useEffect } from 'react'; interface DiagramCanvasProps { children?: React.ReactNode; @@ -7,7 +7,6 @@ interface DiagramCanvasProps { export const DiagramCanvas: React.FC = ({ children }) => { const canvasRef = useRef(null); - const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); const { isPanning, @@ -23,19 +22,7 @@ export const DiagramCanvas: React.FC = ({ children }) => { } }); - // Track container dimensions - const updateDimensions = () => { - if (canvasRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - setContainerDimensions({ width: rect.width, height: rect.height }); - } - }; - - updateDimensions(); - window.addEventListener('resize', updateDimensions); - return () => { - window.removeEventListener('resize', updateDimensions); destroyPaper(); }; } diff --git a/Website/components/diagram/DiagramControls.tsx b/Website/components/diagram/DiagramControls.tsx index 8417470..d1ecd64 100644 --- a/Website/components/diagram/DiagramControls.tsx +++ b/Website/components/diagram/DiagramControls.tsx @@ -14,7 +14,6 @@ import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; export const DiagramControls: React.FC = () => { const { - zoom, resetView, fitToScreen } = useDiagramViewContext(); diff --git a/Website/components/diagram/DiagramRenderer.ts b/Website/components/diagram/DiagramRenderer.ts index 76543f3..63c7ded 100644 --- a/Website/components/diagram/DiagramRenderer.ts +++ b/Website/components/diagram/DiagramRenderer.ts @@ -1,5 +1,6 @@ import { dia } from '@joint/core'; import { AttributeType, EntityType } from '@/lib/Types'; +import { EntityElement } from '@/components/diagram/elements/EntityElement'; export type IPortMap = Record; @@ -81,13 +82,13 @@ export abstract class DiagramRenderer { // Call the appropriate update method based on entity type if (entityElement.get('type') === 'delegate.entity') { // For detailed entities, use updateAttributes - const entityElementTyped = entityElement as any; + 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 any; + const simpleEntityElementTyped = entityElement as unknown as { updateEntity: (entity: EntityType) => void }; if (simpleEntityElementTyped.updateEntity) { simpleEntityElementTyped.updateEntity(updatedEntity); } @@ -124,7 +125,6 @@ export abstract class DiagramRenderer { let portMap: IPortMap; if (el.get('type') === 'delegate.entity') { // For detailed entities, get the actual port map - const EntityElement = require('@/components/diagram/entity/EntityElement').EntityElement; const { portMap: detailedPortMap } = EntityElement.getVisibleItemsAndPorts(entityData); portMap = detailedPortMap; } else { @@ -144,14 +144,14 @@ export abstract class DiagramRenderer { // Recreate links for all entities (this ensures all relationships are updated) const allEntities: EntityType[] = []; - entityMap.forEach((entityInfo, schemaName) => { + entityMap.forEach((entityInfo) => { const entityData = entityInfo.element.get('data')?.entity; if (entityData) { allEntities.push(entityData); } }); - entityMap.forEach((entityInfo, schemaName) => { + entityMap.forEach((entityInfo) => { const entityData = entityInfo.element.get('data')?.entity; if (entityData) { this.createLinks(entityData, entityMap, allEntities); diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index b6e804b..9a25054 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -1,19 +1,14 @@ 'use client'; import React, { useEffect, useMemo, useState, useCallback } from 'react' -import { dia, shapes, util } from '@joint/core' +import { dia, util } from '@joint/core' import { Groups } from "../../generated/Data" -import { EntityElement } from '@/components/diagram/elements/EntityElement'; -import { SimpleEntityElement } from '@/components/diagram/elements/SimpleEntityElement'; import { SquareElement } from '@/components/diagram/elements/SquareElement'; import { TextElement } from '@/components/diagram/elements/TextElement'; -import debounce from 'lodash/debounce'; -import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { PanelLeft, ZoomIn, ZoomOut, Trash2 } from 'lucide-react'; import { DiagramCanvas } from '@/components/diagram/DiagramCanvas'; import { ZoomCoordinateIndicator } from '@/components/diagram/ZoomCoordinateIndicator'; -import { AddEntityPane, EntityActionsPane, LinkPropertiesPane, LinkProperties } from '@/components/diagram/panes'; +import { EntityActionsPane, LinkPropertiesPane, LinkProperties } from '@/components/diagram/panes'; import { SquarePropertiesPane } from '@/components/diagram/panes/SquarePropertiesPane'; import { TextPropertiesPane } from '@/components/diagram/panes/TextPropertiesPane'; import { calculateGridLayout, getDefaultLayoutOptions, calculateEntityHeight } from '@/components/diagram/GridLayoutManager'; @@ -36,9 +31,6 @@ const DiagramContent = () => { zoom, mousePosition, selectGroup, - zoomIn, - zoomOut, - resetView, fitToScreen, addAttributeToEntity, removeAttributeFromEntity, @@ -231,7 +223,7 @@ const DiagramContent = () => { paper.on('link:pointerclick', renderer.onLinkClick); // Handle entity clicks - const handleElementClick = (elementView: any, evt: any) => { + const handleElementClick = (elementView: dia.ElementView, evt: dia.Event) => { evt.stopPropagation(); const element = elementView.model; const elementType = element.get('type'); @@ -273,7 +265,7 @@ const DiagramContent = () => { }; // Handle element hover for cursor indication - const handleElementMouseEnter = (elementView: any) => { + const handleElementMouseEnter = (elementView: dia.ElementView) => { const element = elementView.model; const elementType = element.get('type'); @@ -314,7 +306,7 @@ const DiagramContent = () => { } }; - const handleElementMouseLeave = (elementView: any) => { + const handleElementMouseLeave = (elementView: dia.ElementView) => { const element = elementView.model; const elementType = element.get('type'); @@ -363,7 +355,7 @@ const DiagramContent = () => { paper.on('element:mouseleave', handleElementMouseLeave); // Handle mouse movement over squares to show resize handles only near edges - const handleSquareMouseMove = (cellView: any, evt: any) => { + const handleSquareMouseMove = (cellView: dia.CellView, evt: dia.Event) => { const element = cellView.model; const elementType = element.get('type'); @@ -399,7 +391,7 @@ const DiagramContent = () => { paper.on('cell:mousemove', handleSquareMouseMove); // Handle pointer down for resize handles - capture before other events - paper.on('cell:pointerdown', (cellView: any, evt: any) => { + paper.on('cell:pointerdown', (cellView: dia.CellView, evt: dia.Event) => { const element = cellView.model; const elementType = element.get('type'); @@ -432,7 +424,7 @@ const DiagramContent = () => { handle: selector, startSize: { width: bbox.width, height: bbox.height }, startPosition: { x: bbox.x, y: bbox.y }, - startPointer: { x: evt.clientX, y: evt.clientY } + startPointer: { x: evt.clientX || 0, y: evt.clientY || 0 } }; setResizeData(resizeInfo); @@ -471,8 +463,8 @@ const DiagramContent = () => { const deltaX = evt.clientX - startPointer.x; const deltaY = evt.clientY - startPointer.y; - let newSize = { width: startSize.width, height: startSize.height }; - let newPosition = { x: startPosition.x, y: startPosition.y }; + 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) { diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx index 79539bc..81cf5d0 100644 --- a/Website/components/diagram/SidebarDiagramView.tsx +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -1,13 +1,12 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState } from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Button } from '@/components/ui/button'; -import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Layers, Hammer, Users, Save, Upload, Smartphone, RotateCcw, Trash2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Hammer, Users, Save, Upload, Smartphone, RotateCcw, Trash2 } from 'lucide-react'; import { useDiagramViewContextSafe } from '@/contexts/DiagramViewContext'; import { AddEntityPane, AddGroupPane, ResetToGroupPane } from '@/components/diagram/panes'; -import { DiagramType } from '@/hooks/useDiagram'; import { useIsMobile } from '@/hooks/use-mobile'; -import { Groups } from '../../generated/Data'; +import { GroupType } from '@/lib/Types'; interface ISidebarDiagramViewProps { @@ -34,7 +33,7 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { ); } - const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType, removeEntityFromDiagram, clearDiagram } = diagramContext; + const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType, clearDiagram } = diagramContext; const handleLoadDiagram = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -47,7 +46,7 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { event.target.value = ''; }; - const handleResetToGroup = (group: any) => { + const handleResetToGroup = (group: GroupType) => { // First clear the entire diagram clearDiagram(); // Then add the selected group diff --git a/Website/components/diagram/avoid-router/avoidrouter.ts b/Website/components/diagram/avoid-router/avoidrouter.ts index 38e6087..c423ec6 100644 --- a/Website/components/diagram/avoid-router/avoidrouter.ts +++ b/Website/components/diagram/avoid-router/avoidrouter.ts @@ -1,8 +1,11 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { AvoidLib } from 'libavoid-js'; import { g, util, mvc, dia } from '@joint/core'; const defaultPin = 1; +// The Avoid type is used for static access to the library type Avoid = ReturnType; interface AvoidRouterOptions { diff --git a/Website/components/diagram/elements/EntityElement.ts b/Website/components/diagram/elements/EntityElement.ts index a3c37ea..4000d95 100644 --- a/Website/components/diagram/elements/EntityElement.ts +++ b/Website/components/diagram/elements/EntityElement.ts @@ -1,5 +1,5 @@ import { AttributeType, EntityType } from '@/lib/Types'; -import { dia, util } from '@joint/core'; +import { dia } from '@joint/core'; import { EntityBody } from './EntityBody'; interface IEntityElement { @@ -8,7 +8,7 @@ interface IEntityElement { export class EntityElement extends dia.Element { - initialize(...args: any[]) { + initialize(...args: unknown[]) { super.initialize(...args); const { entity } = this.get('data') as IEntityElement; if (entity) this.updateAttributes(entity); @@ -16,7 +16,7 @@ export class EntityElement extends dia.Element { static getVisibleItemsAndPorts(entity: EntityType) { // Get the visible attributes list - if not set, use default logic - const visibleAttributeSchemaNames = (entity as any).visibleAttributeSchemaNames; + const visibleAttributeSchemaNames = (entity as EntityType & { visibleAttributeSchemaNames?: string[] }).visibleAttributeSchemaNames; if (visibleAttributeSchemaNames) { // Use the explicit visible attributes list @@ -68,8 +68,8 @@ export class EntityElement extends dia.Element { } updateAttributes(entity: EntityType) { - const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); - const selectedKey = (this.get('data') as any)?.selectedKey; + const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); + const selectedKey = (this.get('data') as IEntityElement & { selectedKey?: string })?.selectedKey; const html = EntityBody({ entity, visibleItems, selectedKey }); // Markup diff --git a/Website/components/diagram/elements/SimpleEntityElement.ts b/Website/components/diagram/elements/SimpleEntityElement.ts index 541e2c6..a7794ec 100644 --- a/Website/components/diagram/elements/SimpleEntityElement.ts +++ b/Website/components/diagram/elements/SimpleEntityElement.ts @@ -7,7 +7,7 @@ interface ISimpleEntityElement { export class SimpleEntityElement extends dia.Element { - initialize(...args: any[]) { + initialize(...args: unknown[]) { super.initialize(...args); const { entity } = this.get('data') as ISimpleEntityElement; if (entity) this.updateEntity(entity); diff --git a/Website/components/diagram/elements/SquareElement.ts b/Website/components/diagram/elements/SquareElement.ts index 3e54545..53a6c4c 100644 --- a/Website/components/diagram/elements/SquareElement.ts +++ b/Website/components/diagram/elements/SquareElement.ts @@ -1,4 +1,4 @@ -import { dia, util } from '@joint/core'; +import { dia } from '@joint/core'; import { PRESET_COLORS } from '../shared/DiagramConstants'; export interface SquareElementData { @@ -13,7 +13,7 @@ export interface SquareElementData { export class SquareElement extends dia.Element { - initialize(...args: any[]) { + initialize(...args: unknown[]) { super.initialize(...args); this.updateSquareAttrs(); } diff --git a/Website/components/diagram/elements/TextElement.ts b/Website/components/diagram/elements/TextElement.ts index 38ad7f7..0861018 100644 --- a/Website/components/diagram/elements/TextElement.ts +++ b/Website/components/diagram/elements/TextElement.ts @@ -42,7 +42,7 @@ export class TextElement extends shapes.standard.Rectangle { }, super.defaults); } - constructor(attributes?: any, options?: any) { + constructor(attributes?: dia.Element.Attributes, options?: dia.Graph.Options) { super(attributes, options); // Set initial data if provided @@ -126,7 +126,7 @@ export class TextElement extends shapes.standard.Rectangle { } // Register the custom element -(shapes as any).delegate = { - ...(shapes as any).delegate, +(shapes as Record).delegate = { + ...(shapes as Record).delegate, text: TextElement }; diff --git a/Website/components/diagram/panes/EntityActionsPane.tsx b/Website/components/diagram/panes/EntityActionsPane.tsx index d0ea73b..532c5e8 100644 --- a/Website/components/diagram/panes/EntityActionsPane.tsx +++ b/Website/components/diagram/panes/EntityActionsPane.tsx @@ -3,7 +3,6 @@ import React, { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; diff --git a/Website/components/diagram/panes/LinkPropertiesPane.tsx b/Website/components/diagram/panes/LinkPropertiesPane.tsx index 63acd6a..06cbff3 100644 --- a/Website/components/diagram/panes/LinkPropertiesPane.tsx +++ b/Website/components/diagram/panes/LinkPropertiesPane.tsx @@ -7,7 +7,6 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; -import { Trash2 } from 'lucide-react'; import { dia } from '@joint/core'; import { PRESET_COLORS, LINE_STYLES, STROKE_WIDTHS } from '../shared/DiagramConstants'; @@ -92,32 +91,6 @@ export const LinkPropertiesPane: React.FC = ({ setCustomColor(newColor); }; - const getLinkSourceTarget = () => { - if (!selectedLink) return null; - - const source = selectedLink.source(); - const target = selectedLink.target(); - - // Try to get entity names from graph elements - let sourceName = 'Unknown'; - let targetName = 'Unknown'; - - // This is a simplified approach - in a real app you might pass the graph or entity map - if (source.id) { - sourceName = `Entity ${source.id}`; - } - if (target.id) { - targetName = `Entity ${target.id}`; - } - - return { - source: sourceName, - target: targetName - }; - }; - - const sourceTarget = getLinkSourceTarget(); - return ( diff --git a/Website/components/diagram/panes/SquarePropertiesPane.tsx b/Website/components/diagram/panes/SquarePropertiesPane.tsx index fc3c90f..77e7b06 100644 --- a/Website/components/diagram/panes/SquarePropertiesPane.tsx +++ b/Website/components/diagram/panes/SquarePropertiesPane.tsx @@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; -import { Palette, Square, Trash2 } from 'lucide-react'; +import { Square, Trash2 } from 'lucide-react'; import { SquareElement, SquareElementData } from '../elements/SquareElement'; import { PRESET_COLORS } from '../shared/DiagramConstants'; @@ -44,7 +44,7 @@ export const SquarePropertiesPane: React.FC = ({ } }, [selectedSquare]); - const handleDataChange = (key: keyof SquareElementData, value: any) => { + const handleDataChange = (key: keyof SquareElementData, value: string | number) => { const newData = { ...squareData, [key]: value }; setSquareData(newData); diff --git a/Website/components/diagram/panes/TextPropertiesPane.tsx b/Website/components/diagram/panes/TextPropertiesPane.tsx index 8dc2223..7a06df3 100644 --- a/Website/components/diagram/panes/TextPropertiesPane.tsx +++ b/Website/components/diagram/panes/TextPropertiesPane.tsx @@ -72,7 +72,7 @@ export const TextPropertiesPane: React.FC = ({ } }, [textData, selectedText]); - const handleDataChange = (key: keyof TextElementData, value: any) => { + const handleDataChange = (key: keyof TextElementData, value: string | number) => { setTextData(prev => ({ ...prev, [key]: value })); }; diff --git a/Website/components/diagram/renderers/DetailedDiagramRender.ts b/Website/components/diagram/renderers/DetailedDiagramRender.ts index 9c21b0c..9b209dc 100644 --- a/Website/components/diagram/renderers/DetailedDiagramRender.ts +++ b/Website/components/diagram/renderers/DetailedDiagramRender.ts @@ -1,6 +1,5 @@ // DetailedDiagramRender.ts import { dia, shapes } from '@joint/core'; -import { SimpleEntityElement } from '@/components/diagram/elements/SimpleEntityElement'; import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; import { EntityElement } from '../elements/EntityElement'; import { AttributeType, EntityType } from '@/lib/Types'; @@ -25,7 +24,7 @@ export class DetailedDiagramRender extends DiagramRenderer { } createEntity(entity: EntityType, position: { x: number, y: number }) { - const { visibleItems, portMap } = EntityElement.getVisibleItemsAndPorts(entity); + const { portMap } = EntityElement.getVisibleItemsAndPorts(entity); const entityElement = new EntityElement({ position, data: { entity } diff --git a/Website/components/diagram/renderers/SimpleDiagramRender.ts b/Website/components/diagram/renderers/SimpleDiagramRender.ts index a70a38c..f391085 100644 --- a/Website/components/diagram/renderers/SimpleDiagramRender.ts +++ b/Website/components/diagram/renderers/SimpleDiagramRender.ts @@ -2,12 +2,11 @@ import { dia, shapes } from '@joint/core'; import { SimpleEntityElement } from '@/components/diagram/elements/SimpleEntityElement'; import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; -import { EntityElement } from '../elements/EntityElement'; import { AttributeType, EntityType } from '@/lib/Types'; export class SimpleDiagramRenderer extends DiagramRenderer { - onDocumentClick(event: MouseEvent): void { } + onDocumentClick(): void { } createEntity(entity: EntityType, position: { x: number, y: number }) { const entityElement = new SimpleEntityElement({ @@ -130,7 +129,7 @@ export class SimpleDiagramRenderer extends DiagramRenderer { }); } - updateEntityAttributes(graph: dia.Graph, selectedKey: string | undefined): void { + updateEntityAttributes(): void { // Simple entities don't display key attributes, so nothing to do } diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 5d961a1..6aa4e95 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -546,8 +546,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { const text = await file.text(); const diagramData = JSON.parse(text); - console.log('Parsed diagram data:', diagramData); - if (!graphRef.current || !paperRef.current) { console.warn('Graph or paper not available for loading'); return; @@ -558,8 +556,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { // Use JointJS built-in JSON import if (diagramData.graph) { - console.log('Loading graph data:', diagramData.graph); - // Manual recreation approach since cellNamespace isn't working const cells = diagramData.graph.cells || []; @@ -567,7 +563,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { let cell; if (cellData.type === 'delegate.square') { - console.log('Creating SquareElement:', cellData); cell = new SquareElement({ id: cellData.id, position: cellData.position, @@ -576,7 +571,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { data: cellData.data }); } else if (cellData.type === 'delegate.text') { - console.log('Creating TextElement:', cellData); cell = new TextElement({ id: cellData.id, position: cellData.position, @@ -599,20 +593,17 @@ export const useDiagram = (): DiagramState & DiagramActions => { } }); - console.log('Graph loaded successfully'); } else { console.warn('No graph data found in diagram file'); } // Restore diagram type if (diagramData.diagramType) { - console.log('Restoring diagram type:', diagramData.diagramType); setDiagramType(diagramData.diagramType); } // Restore entities if (diagramData.currentEntities) { - console.log('Restoring entities:', diagramData.currentEntities.length); setCurrentEntities(diagramData.currentEntities); } @@ -621,19 +612,15 @@ export const useDiagram = (): DiagramState & DiagramActions => { const { panPosition: savedPanPosition, zoom: savedZoom } = diagramData.viewState; if (savedZoom && paperRef.current) { - console.log('Restoring zoom:', savedZoom); paperRef.current.scale(savedZoom, savedZoom); updateZoomDisplay(savedZoom); } if (savedPanPosition && paperRef.current) { - console.log('Restoring pan position:', savedPanPosition); paperRef.current.translate(savedPanPosition.x, savedPanPosition.y); setPanPosition(savedPanPosition); } } - - console.log('Diagram loaded successfully'); } catch (error) { console.error('Failed to load diagram:', error); console.error('Error stack:', error instanceof Error ? error.stack : 'No stack trace'); @@ -656,7 +643,6 @@ export const useDiagram = (): DiagramState & DiagramActions => { // Clear selection clearSelection(); - console.log('Diagram cleared successfully'); }, [graphRef, clearSelection, setCurrentEntities]); const initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { From ccf61ddabdd4e4592fd3805e840a621d1c1f554b Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 17:54:14 +0200 Subject: [PATCH 35/45] chore: more housecleaning --- Website/components/diagram/DiagramView.tsx | 8 +- .../diagram/elements/EntityElement.ts | 2 +- .../diagram/elements/SimpleEntityElement.ts | 2 +- .../diagram/elements/SquareElement.ts | 2 +- .../diagram/elements/TextElement.ts | 2 +- .../diagram/panes/AddAttributePane.tsx | 182 ++++++++++++++++++ Website/hooks/useDiagram.ts | 6 +- 7 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 Website/components/diagram/panes/AddAttributePane.tsx diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 9a25054..7081744 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -296,7 +296,7 @@ const DiagramContent = () => { // Find the foreignObject and its HTML content for the border effect const foreignObject = elementView.el.querySelector('foreignObject'); - const htmlContent = foreignObject?.querySelector('[data-entity-schema]'); + const htmlContent = foreignObject?.querySelector('[data-entity-schema]') as HTMLElement; if (htmlContent && !htmlContent.hasAttribute('data-hover-active')) { htmlContent.setAttribute('data-hover-active', 'true'); @@ -341,7 +341,7 @@ const DiagramContent = () => { // Remove border from HTML content const foreignObject = elementView.el.querySelector('foreignObject'); - const htmlContent = foreignObject?.querySelector('[data-entity-schema]'); + const htmlContent = foreignObject?.querySelector('[data-entity-schema]') as HTMLElement; if (htmlContent) { htmlContent.removeAttribute('data-hover-active'); @@ -362,6 +362,10 @@ const DiagramContent = () => { 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 diff --git a/Website/components/diagram/elements/EntityElement.ts b/Website/components/diagram/elements/EntityElement.ts index 4000d95..7fefef9 100644 --- a/Website/components/diagram/elements/EntityElement.ts +++ b/Website/components/diagram/elements/EntityElement.ts @@ -8,7 +8,7 @@ interface IEntityElement { export class EntityElement extends dia.Element { - initialize(...args: unknown[]) { + initialize(...args: Parameters) { super.initialize(...args); const { entity } = this.get('data') as IEntityElement; if (entity) this.updateAttributes(entity); diff --git a/Website/components/diagram/elements/SimpleEntityElement.ts b/Website/components/diagram/elements/SimpleEntityElement.ts index a7794ec..f667d93 100644 --- a/Website/components/diagram/elements/SimpleEntityElement.ts +++ b/Website/components/diagram/elements/SimpleEntityElement.ts @@ -7,7 +7,7 @@ interface ISimpleEntityElement { export class SimpleEntityElement extends dia.Element { - initialize(...args: unknown[]) { + initialize(...args: Parameters) { super.initialize(...args); const { entity } = this.get('data') as ISimpleEntityElement; if (entity) this.updateEntity(entity); diff --git a/Website/components/diagram/elements/SquareElement.ts b/Website/components/diagram/elements/SquareElement.ts index 53a6c4c..8833278 100644 --- a/Website/components/diagram/elements/SquareElement.ts +++ b/Website/components/diagram/elements/SquareElement.ts @@ -13,7 +13,7 @@ export interface SquareElementData { export class SquareElement extends dia.Element { - initialize(...args: unknown[]) { + initialize(...args: Parameters) { super.initialize(...args); this.updateSquareAttrs(); } diff --git a/Website/components/diagram/elements/TextElement.ts b/Website/components/diagram/elements/TextElement.ts index 0861018..5bc2af8 100644 --- a/Website/components/diagram/elements/TextElement.ts +++ b/Website/components/diagram/elements/TextElement.ts @@ -127,6 +127,6 @@ export class TextElement extends shapes.standard.Rectangle { // Register the custom element (shapes as Record).delegate = { - ...(shapes as Record).delegate, + ...((shapes as Record).delegate || {}), text: TextElement }; diff --git a/Website/components/diagram/panes/AddAttributePane.tsx b/Website/components/diagram/panes/AddAttributePane.tsx new file mode 100644 index 0000000..c8fc130 --- /dev/null +++ b/Website/components/diagram/panes/AddAttributePane.tsx @@ -0,0 +1,182 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + Type, + Calendar, + Hash, + Search, + DollarSign, + ToggleLeft, + FileText, + List, + Activity, +} from 'lucide-react'; +import { AttributeType } from '@/lib/Types'; + +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 Type; + case 'IntegerAttribute': return Hash; + case 'DecimalAttribute': return DollarSign; + case 'DateTimeAttribute': return Calendar; + case 'BooleanAttribute': return ToggleLeft; + case 'ChoiceAttribute': return List; + case 'LookupAttribute': return Search; + case 'FileAttribute': return FileText; + case 'StatusAttribute': return Activity; + default: return Type; + } +}; + +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 */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search by attribute name..." + /> +
+ + {/* Available Attributes */} +
+ +
+
+ {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)} + > + +
+
+ +
+
+
+ {attribute.DisplayName} +
+
+ {typeLabel} • {attribute.SchemaName} +
+
+ {attribute.Description && ( + + +
+ ? +
+
+ +

{attribute.Description}

+
+
+ )} +
+
+
+ ); + })} +
+ )} +
+
+
+ + + + +
+
+
+
+ ); +}; diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 6aa4e95..23f048d 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -579,9 +579,11 @@ export const useDiagram = (): DiagramState & DiagramActions => { data: cellData.data }); } else { - // Use fromJSON for standard elements try { - cell = dia.Cell.fromJSON(cellData); + // 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; From 3d560b5a50c74e6af7d384cdf37636d20db4e266 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 18:53:14 +0200 Subject: [PATCH 36/45] chore: package lock changes & minor change to relationship label element --- Website/components/diagram/DiagramView.tsx | 25 +++++++++++++++------- Website/package-lock.json | 10 +-------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 7081744..896740c 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -616,18 +616,27 @@ const DiagramContent = () => { if (properties.label) { link.label(0, { attrs: { + rect: { + fill: 'white', + stroke: '#e5e7eb', + strokeWidth: 1, + rx: 4, + ry: 4, + ref: 'text', + refX: -16, + refY: -4, + 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' - }, - rect: { - fill: 'white', - stroke: properties.color, - strokeWidth: 1, - rx: 3, - ry: 3 + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + textAnchor: 'start', + dominantBaseline: 'central', } }, position: { diff --git a/Website/package-lock.json b/Website/package-lock.json index 08c9161..4c7f1e3 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -24,7 +24,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^5.9.6", - "lodash": "^4.17.21", + "libavoid-js": "^0.4.5", "lucide-react": "^0.462.0", "next": "^15.3.4", "react": "^19.0.0", @@ -41,7 +41,6 @@ "@types/react-window": "^1.8.8", "eslint": "^8", "eslint-config-next": "15.0.3", - "libavoid-js": "^0.4.5", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" @@ -6004,7 +6003,6 @@ "version": "0.4.5", "resolved": "https://registry.npmjs.org/libavoid-js/-/libavoid-js-0.4.5.tgz", "integrity": "sha512-9BrYRXAQ+nmLuHZSqf4z52YN8TroBPxyqo6A6h6Pj03j5UYNx/Hhnd/rg+kiLVrE76wzBeBVO3OW7kaEpzYC9Q==", - "dev": true, "license": "LGPL-2.1-or-later" }, "node_modules/lilconfig": { @@ -6035,12 +6033,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", From 4c12e271bd0ac8faee31c8011323caab592f10a1 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 19:06:47 +0200 Subject: [PATCH 37/45] chore: removed old cursor file, altered copilot instructions --- Website/.cursor/rules/ai-rules.md | 1 - Website/.cursor/rules/rule.mdc | 6 ------ Website/.github/instructions/copilot.instructions.md | 2 +- 3 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 Website/.cursor/rules/ai-rules.md delete mode 100644 Website/.cursor/rules/rule.mdc diff --git a/Website/.cursor/rules/ai-rules.md b/Website/.cursor/rules/ai-rules.md deleted file mode 100644 index 1aa67a7..0000000 --- a/Website/.cursor/rules/ai-rules.md +++ /dev/null @@ -1 +0,0 @@ -Read the file at ai-rules/READNE.md. When answering my questions, you MUST always follow those rules. \ No newline at end of file diff --git a/Website/.cursor/rules/rule.mdc b/Website/.cursor/rules/rule.mdc deleted file mode 100644 index c336dfc..0000000 --- a/Website/.cursor/rules/rule.mdc +++ /dev/null @@ -1,6 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -Read the .cursor/rules/ai-rules.md file \ No newline at end of file diff --git a/Website/.github/instructions/copilot.instructions.md b/Website/.github/instructions/copilot.instructions.md index 0df4db1..2d000e6 100644 --- a/Website/.github/instructions/copilot.instructions.md +++ b/Website/.github/instructions/copilot.instructions.md @@ -1,4 +1,4 @@ --- applyTo: '**' --- -Read ai-rules/README.md for instructions on how to use this file. \ No newline at end of file +Read file at `ai-rules/README.md` for instructions on how to use this file. \ No newline at end of file From 10f9588ee01b04944c5dc6f1a5543ed8d8769033 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 19:38:45 +0200 Subject: [PATCH 38/45] chore: added /diagram to middleware. And made loading indication on diagram loading. Fixed text alignment and added open beta disclaimer. --- Website/components/diagram/DiagramView.tsx | 65 ++++++++++++++++++---- Website/middleware.ts | 2 +- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 896740c..1111bf9 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -6,6 +6,7 @@ import { Groups } from "../../generated/Data" import { SquareElement } from '@/components/diagram/elements/SquareElement'; import { TextElement } from '@/components/diagram/elements/TextElement'; import { Separator } from '@/components/ui/separator'; +import { Loading } from '@/components/ui/loading'; import { DiagramCanvas } from '@/components/diagram/DiagramCanvas'; import { ZoomCoordinateIndicator } from '@/components/diagram/ZoomCoordinateIndicator'; import { EntityActionsPane, LinkPropertiesPane, LinkProperties } from '@/components/diagram/panes'; @@ -40,6 +41,12 @@ const DiagramContent = () => { const [selectedKey, setSelectedKey] = useState(); const [selectedEntityForActions, setSelectedEntityForActions] = useState(); + const [isLoading, setIsLoading] = useState(true); + + // Debug logging for loading state changes + useEffect(() => { + console.log('Loading state changed to:', isLoading); + }, [isLoading]); // Wrapper for setSelectedKey to pass to renderer const handleSetSelectedKey = useCallback((key: string | undefined) => { @@ -105,6 +112,17 @@ const DiagramContent = () => { useEffect(() => { if (!graph || !paper || !selectedGroup || !renderer) return; + // Set loading state when starting diagram creation + console.log('Starting diagram creation, setting loading to true'); + setIsLoading(true); + + // If there are no entities, set loading to false immediately + if (currentEntities.length === 0) { + console.log('No entities to render, setting loading to false'); + setIsLoading(false); + return; + } + // Preserve squares and text elements before clearing - only clear entities and links const squares = graph.getElements().filter(element => element.get('type') === 'delegate.square'); const textElements = graph.getElements().filter(element => element.get('type') === 'delegate.text'); @@ -186,7 +204,10 @@ const DiagramContent = () => { // Auto-fit to screen after a short delay to ensure all elements are rendered setTimeout(() => { + console.log('Diagram creation complete, setting loading to false'); fitToScreen(); + // Set loading to false once diagram is complete + setIsLoading(false); }, 200); }, [graph, paper, selectedGroup, currentEntities, diagramType]); @@ -623,8 +644,8 @@ const DiagramContent = () => { rx: 4, ry: 4, ref: 'text', - refX: -16, - refY: -4, + refX: -8, + refY: 0, refWidth: '100%', refHeight: '100%', refWidth2: 16, @@ -664,22 +685,44 @@ const DiagramContent = () => { return ( <>
- {/* Top Toolbar */} -
-
-
-

Data Model Diagram

- + {/* Beta Disclaimer Banner */} +
+
+
+ β
+

+ Open Beta Feature: This ER Diagram feature is currently in beta. Some functionality may not work fully. +

- - {/* Diagram Area */} -
+ {isLoading && ( +
+
+
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+

+ Loading diagram... +

+
+
+ )} {/* Zoom and Coordinate Indicator */} Date: Sat, 9 Aug 2025 20:05:36 +0200 Subject: [PATCH 39/45] chore: minor change to default diagratype and fix to loading diagram spinner --- Website/components/diagram/DiagramView.tsx | 26 +++--- .../components/diagram/SidebarDiagramView.tsx | 6 +- .../diagram/avoid-router/avoidrouter.ts | 15 +++- Website/hooks/useDiagram.ts | 81 ++++++++++++------- 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 1111bf9..1d178b1 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -43,11 +43,6 @@ const DiagramContent = () => { const [selectedEntityForActions, setSelectedEntityForActions] = useState(); const [isLoading, setIsLoading] = useState(true); - // Debug logging for loading state changes - useEffect(() => { - console.log('Loading state changed to:', isLoading); - }, [isLoading]); - // Wrapper for setSelectedKey to pass to renderer const handleSetSelectedKey = useCallback((key: string | undefined) => { setSelectedKey(key); @@ -95,9 +90,21 @@ const DiagramContent = () => { }, [diagramType, graph, handleSetSelectedKey, handleLinkClick]); useEffect(() => { - if (Groups.length > 0 && !selectedGroup) selectGroup(Groups[0]); + 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; @@ -110,15 +117,15 @@ const DiagramContent = () => { }, [renderer]); useEffect(() => { - if (!graph || !paper || !selectedGroup || !renderer) return; + if (!graph || !paper || !selectedGroup || !renderer) { + return; + } // Set loading state when starting diagram creation - console.log('Starting diagram creation, setting loading to true'); setIsLoading(true); // If there are no entities, set loading to false immediately if (currentEntities.length === 0) { - console.log('No entities to render, setting loading to false'); setIsLoading(false); return; } @@ -204,7 +211,6 @@ const DiagramContent = () => { // Auto-fit to screen after a short delay to ensure all elements are rendered setTimeout(() => { - console.log('Diagram creation complete, setting loading to false'); fitToScreen(); // Set loading to false once diagram is complete setIsLoading(false); diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx index 81cf5d0..8405835 100644 --- a/Website/components/diagram/SidebarDiagramView.tsx +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -59,11 +59,11 @@ export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { return (
- - + + - + diff --git a/Website/components/diagram/avoid-router/avoidrouter.ts b/Website/components/diagram/avoid-router/avoidrouter.ts index c423ec6..e676263 100644 --- a/Website/components/diagram/avoid-router/avoidrouter.ts +++ b/Website/components/diagram/avoid-router/avoidrouter.ts @@ -30,8 +30,21 @@ export class AvoidRouter { commitTransactions: boolean; graphListener?: mvc.Listener; + private static isLoaded = false; + static async load(): Promise { - await AvoidLib.load("/libavoid.wasm"); + if (AvoidRouter.isLoaded) { + console.log('Avoid library is already initialized'); + return; + } + + try { + await AvoidLib.load("/libavoid.wasm"); + AvoidRouter.isLoaded = true; + } catch (error) { + console.error('Failed to load Avoid library:', error); + throw error; + } } constructor(graph: dia.Graph, options: AvoidRouterOptions = {}) { diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts index 23f048d..d281449 100644 --- a/Website/hooks/useDiagram.ts +++ b/Website/hooks/useDiagram.ts @@ -10,7 +10,7 @@ import { SimpleDiagramRenderer } from '@/components/diagram/renderers/SimpleDiag import { DetailedDiagramRender } from '@/components/diagram/renderers/DetailedDiagramRender'; import { PRESET_COLORS } from '@/components/diagram/shared/DiagramConstants'; -export type DiagramType = 'detailed' | 'simple'; +export type DiagramType = 'simple' | 'detailed'; export interface DiagramState { zoom: number; @@ -67,7 +67,11 @@ export const useDiagram = (): DiagramState & DiagramActions => { 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('detailed'); + 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) => { @@ -651,35 +655,48 @@ export const useDiagram = (): DiagramState & DiagramActions => { // Create graph if it doesn't exist if (!graphRef.current) { graphRef.current = new dia.Graph(); + setGraphInitialized(true); } - await AvoidRouter.load(); - const 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; - } + 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; + 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; + // 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; + 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({ @@ -714,6 +731,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { }); paperRef.current = paper; + setPaperInitialized(true); // Setup event listeners paper.on('blank:pointerdown', () => { @@ -802,7 +820,7 @@ export const useDiagram = (): DiagramState & DiagramActions => { }; return paper; - }, [updateZoomDisplay, updatePanningDisplay, updateMousePosition, updatePanPosition]); + }, [updateZoomDisplay, updatePanningDisplay, updateMousePosition, updatePanPosition, setGraphInitialized, setPaperInitialized]); const destroyPaper = useCallback(() => { if (cleanupRef.current) { @@ -810,7 +828,10 @@ export const useDiagram = (): DiagramState & DiagramActions => { cleanupRef.current = null; } paperRef.current = null; - }, []); + graphRef.current = null; + setPaperInitialized(false); + setGraphInitialized(false); + }, [setPaperInitialized, setGraphInitialized]); // Cleanup on unmount useEffect(() => { @@ -824,8 +845,8 @@ export const useDiagram = (): DiagramState & DiagramActions => { zoom, isPanning, selectedElements, - paper: paperRef.current, - graph: graphRef.current, + paper: paperInitialized ? paperRef.current : null, + graph: graphInitialized ? graphRef.current : null, selectedGroup, currentEntities, mousePosition, From e7661800325f27cc7f800b0cffd37ed1d1cd01db Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 20:28:40 +0200 Subject: [PATCH 40/45] fix: dont use full sized entities for grid layout when using simple diagram type. --- Website/components/diagram/DiagramView.tsx | 12 ++-- .../components/diagram/GridLayoutManager.ts | 64 ++++++++++++++----- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 1d178b1..b4c4e2f 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -168,7 +168,7 @@ const DiagramContent = () => { }); // Calculate grid layout - const layoutOptions = getDefaultLayoutOptions(); + const layoutOptions = getDefaultLayoutOptions(diagramType); // Get actual container dimensions const containerRect = paper?.el?.getBoundingClientRect(); @@ -179,17 +179,19 @@ const DiagramContent = () => { const updatedLayoutOptions = { ...layoutOptions, containerWidth: actualContainerWidth, - containerHeight: actualContainerHeight + containerHeight: actualContainerHeight, + diagramType: diagramType }; - // Calculate actual heights for each entity - const entityHeights = currentEntities.map(entity => calculateEntityHeight(entity)); + // Calculate actual heights for each entity based on diagram type + const entityHeights = currentEntities.map(entity => calculateEntityHeight(entity, diagramType)); const maxEntityHeight = Math.max(...entityHeights, layoutOptions.entityHeight); // Use the maximum height for layout calculation to ensure proper spacing const adjustedLayoutOptions = { ...updatedLayoutOptions, - entityHeight: maxEntityHeight + entityHeight: maxEntityHeight, + diagramType: diagramType }; const layout = calculateGridLayout(currentEntities, adjustedLayoutOptions); diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts index 6450880..9edc391 100644 --- a/Website/components/diagram/GridLayoutManager.ts +++ b/Website/components/diagram/GridLayoutManager.ts @@ -1,6 +1,8 @@ import { EntityType } from '@/lib/Types'; import { EntityElement } from '@/components/diagram/elements/EntityElement'; +export type DiagramType = 'simple' | 'detailed'; + export interface GridLayoutOptions { containerWidth: number; containerHeight: number; @@ -8,6 +10,7 @@ export interface GridLayoutOptions { entityHeight: number; padding: number; margin: number; + diagramType?: DiagramType; } export interface GridPosition { @@ -24,9 +27,15 @@ export interface GridLayoutResult { } /** - * Calculates the actual height of an entity based on its visible attributes + * Calculates the actual height of an entity based on its visible attributes and diagram type */ -export const calculateEntityHeight = (entity: EntityType): number => { +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; @@ -67,7 +76,7 @@ export const calculateGridLayout = ( for (let i = 0; i < entities.length; i++) { const entity = entities[i]; - const height = calculateEntityHeight(entity); + const height = calculateEntityHeight(entity, options.diagramType); // Assign to the column with the least cumulative height const colIndex = columnHeights.indexOf(Math.min(...columnHeights)); @@ -94,12 +103,20 @@ export const calculateGridLayout = ( /** - * Estimates entity dimensions based on content + * Estimates entity dimensions based on content and diagram type */ -export const estimateEntityDimensions = (entity: EntityType): { width: number; height: number } => { - // Base dimensions +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); // Use actual calculated height + const height = calculateEntityHeight(entity, diagramType); // Use actual calculated height return { width: baseWidth, @@ -108,13 +125,28 @@ export const estimateEntityDimensions = (entity: EntityType): { width: number; h }; /** - * Gets default layout options + * Gets default layout options based on diagram type */ -export const getDefaultLayoutOptions = (): GridLayoutOptions => ({ - 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 -}); \ No newline at end of file +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 From 84c29c5e67813f3d80c04d7eb55f8052630d4afa Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 20:53:00 +0200 Subject: [PATCH 41/45] fix: removed reset of layout when new entities or existing are edited --- Website/components/diagram/DiagramView.tsx | 113 +++++++++++++++--- .../components/diagram/GridLayoutManager.ts | 103 +++++++++++++--- 2 files changed, 180 insertions(+), 36 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index b4c4e2f..b7dd217 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useMemo, useState, useCallback } from 'react' +import React, { useEffect, useMemo, useState, useCallback, useRef } from 'react' import { dia, util } from '@joint/core' import { Groups } from "../../generated/Data" import { SquareElement } from '@/components/diagram/elements/SquareElement'; @@ -12,7 +12,7 @@ import { ZoomCoordinateIndicator } from '@/components/diagram/ZoomCoordinateIndi import { EntityActionsPane, LinkPropertiesPane, LinkProperties } from '@/components/diagram/panes'; import { SquarePropertiesPane } from '@/components/diagram/panes/SquarePropertiesPane'; import { TextPropertiesPane } from '@/components/diagram/panes/TextPropertiesPane'; -import { calculateGridLayout, getDefaultLayoutOptions, calculateEntityHeight } from '@/components/diagram/GridLayoutManager'; +import { calculateGridLayout, getDefaultLayoutOptions, calculateEntityHeight, estimateEntityDimensions } from '@/components/diagram/GridLayoutManager'; import { AttributeType } from '@/lib/Types'; import { AppSidebar } from '../AppSidebar'; import { DiagramViewProvider, useDiagramViewContext } from '@/contexts/DiagramViewContext'; @@ -42,6 +42,9 @@ const DiagramContent = () => { const [selectedKey, setSelectedKey] = useState(); const [selectedEntityForActions, setSelectedEntityForActions] = useState(); const [isLoading, setIsLoading] = useState(true); + + // Persistent tracking of entity positions across renders + const entityPositionsRef = useRef>(new Map()); // Wrapper for setSelectedKey to pass to renderer const handleSetSelectedKey = useCallback((key: string | undefined) => { @@ -130,9 +133,14 @@ const DiagramContent = () => { return; } - // Preserve squares and text elements before clearing - only clear entities and links + // 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'), @@ -146,6 +154,28 @@ const DiagramContent = () => { size: textElement.size() })); + // Update persistent position tracking with current positions + console.log('🔍 Before update - entityPositionsRef has:', Array.from(entityPositionsRef.current.keys())); + existingEntities.forEach(element => { + const entityData = element.get('data'); + if (entityData?.entity?.SchemaName) { + const position = element.position(); + entityPositionsRef.current.set(entityData.entity.SchemaName, position); + console.log(`📍 Updated position for ${entityData.entity.SchemaName}:`, position); + } + }); + + // Clean up position tracking for entities that are no longer in currentEntities + const currentEntityNames = new Set(currentEntities.map(e => e.SchemaName)); + console.log('📋 Current entities:', Array.from(currentEntityNames)); + for (const [schemaName] of entityPositionsRef.current) { + if (!currentEntityNames.has(schemaName)) { + console.log(`🗑️ Removing position tracking for deleted entity: ${schemaName}`); + entityPositionsRef.current.delete(schemaName); + } + } + console.log('🔍 After cleanup - entityPositionsRef has:', Array.from(entityPositionsRef.current.keys())); + // Clear existing elements graph.clear(); @@ -183,28 +213,77 @@ const DiagramContent = () => { diagramType: diagramType }; - // Calculate actual heights for each entity based on diagram type - const entityHeights = currentEntities.map(entity => calculateEntityHeight(entity, diagramType)); - const maxEntityHeight = Math.max(...entityHeights, layoutOptions.entityHeight); - - // Use the maximum height for layout calculation to ensure proper spacing - const adjustedLayoutOptions = { - ...updatedLayoutOptions, - entityHeight: maxEntityHeight, - 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) + ); - const layout = calculateGridLayout(currentEntities, adjustedLayoutOptions); + console.log('🆕 New entities (no tracked position):', newEntities.map(e => e.SchemaName)); + console.log('📌 Existing entities (have tracked position):', existingEntitiesWithPositions.map(e => e.SchemaName)); // Store entity elements and port maps by SchemaName for easy lookup const entityMap = new Map(); - // Create entities in grid layout - currentEntities.forEach((entity, index) => { - const position = layout.positions[index] || { x: 50, y: 50 }; + const placedEntityPositions: { x: number; y: number; width: number; height: number }[] = []; + + // First, create existing entities with their preserved positions + console.log('🔧 Creating existing entities with preserved positions...'); + existingEntitiesWithPositions.forEach((entity) => { + const position = entityPositionsRef.current.get(entity.SchemaName); + if (!position) return; // Skip if position is undefined + + console.log(`📍 Placing existing entity ${entity.SchemaName} at:`, position); 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 + }); }); + console.log('🚧 Collision avoidance positions:', placedEntityPositions); + + // Then, create new entities with grid layout that avoids already placed entities + if (newEntities.length > 0) { + console.log('🆕 Creating new entities with grid layout...'); + // 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 + }; + + console.log('📊 Grid layout options:', adjustedLayoutOptions); + console.log('🚧 Avoiding existing positions:', placedEntityPositions); + + const layout = calculateGridLayout(newEntities, adjustedLayoutOptions, placedEntityPositions); + console.log('📐 Calculated grid positions:', layout.positions); + + // Create new entities with grid layout positions + newEntities.forEach((entity, index) => { + const position = layout.positions[index] || { x: 50, y: 50 }; + console.log(`🆕 Placing new entity ${entity.SchemaName} at:`, position); + 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); + console.log(`💾 Saved position for ${entity.SchemaName}:`, position); + }); + } else { + console.log('✅ No new entities to place with grid layout'); + } + util.nextFrame(() => { currentEntities.forEach(entity => { renderer.createLinks(entity, entityMap, currentEntities); diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts index 9edc391..afa2c79 100644 --- a/Website/components/diagram/GridLayoutManager.ts +++ b/Website/components/diagram/GridLayoutManager.ts @@ -49,10 +49,12 @@ export const calculateEntityHeight = (entity: EntityType, diagramType: DiagramTy /** * Calculates optimal grid layout for entities based on screen aspect ratio + * Optionally avoids existing entity positions */ export const calculateGridLayout = ( entities: EntityType[], - options: GridLayoutOptions + options: GridLayoutOptions, + existingPositions?: { x: number; y: number; width: number; height: number }[] ): GridLayoutResult => { const { containerWidth, entityWidth, padding, margin } = options; @@ -66,38 +68,101 @@ export const calculateGridLayout = ( }; } + // If we have existing positions, we need to find the best starting position for new entities + let startColumn = 0; + let startRow = 0; + + console.log('🔍 GridLayout: existingPositions received:', existingPositions); + + 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)); + + console.log('📏 GridLayout: maxX:', maxX, 'maxY:', maxY); + + // Start new entities to the right of existing ones, or on the next row + startColumn = Math.floor((maxX + padding - margin) / (entityWidth + padding)); + if (startColumn * (entityWidth + padding) + margin + entityWidth > containerWidth) { + // Move to next row if we can't fit horizontally + startColumn = 0; + startRow = Math.floor((maxY + padding - margin) / 200); // Approximate row height + } + + console.log('📍 GridLayout: calculated startColumn:', startColumn, 'startRow:', startRow); + } else { + console.log('📍 GridLayout: no existing positions, starting from origin'); + } + // Determine how many columns can fit const maxColumns = Math.max(1, Math.floor((containerWidth - margin * 2 + padding) / (entityWidth + padding))); - const columns = Math.min(maxColumns, entities.length); - - // Initialize arrays to track column heights - const columnHeights: number[] = Array(columns).fill(0); + + // For collision avoidance, we'll place entities sequentially from the calculated starting position const positions: GridPosition[] = []; + let currentColumn = startColumn; + let currentRow = startRow; + + console.log('📐 GridLayout: maxColumns:', maxColumns, 'starting at column:', currentColumn, 'row:', currentRow); for (let i = 0; i < entities.length; i++) { const entity = entities[i]; const height = calculateEntityHeight(entity, options.diagramType); - - // Assign to the column with the least cumulative height - const colIndex = columnHeights.indexOf(Math.min(...columnHeights)); - const x = margin + colIndex * (entityWidth + padding); - const y = margin + columnHeights[colIndex]; - - positions.push({ x, y }); - - // Add height + padding to the selected column - columnHeights[colIndex] += height + padding; + + // 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 * (entityWidth + 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 => + Math.abs(pos.x - x) < entityWidth + padding/2 && + Math.abs(pos.y - y) < height + padding/2 + ) : false; + + if (!isOccupied) { + console.log(`📍 GridLayout: placing ${entity.SchemaName} at column ${currentColumn}, row ${currentRow} (${x}, ${y})`); + positions.push({ x, y }); + foundValidPosition = true; + } else { + console.log(`🚫 GridLayout: position (${x}, ${y}) occupied by existing entity, trying next...`); + } + + // Move to next position + currentColumn++; + attempts++; + } + + if (!foundValidPosition) { + console.warn(`⚠️ GridLayout: Could not find valid position for ${entity.SchemaName}, using fallback`); + // Fallback: place at calculated position anyway (should not happen with enough attempts) + const x = margin + currentColumn * (entityWidth + padding); + const y = margin + currentRow * (height + padding); + positions.push({ x, y }); + currentColumn++; + } } + + console.log('✅ GridLayout: final positions:', positions); - const gridWidth = columns * entityWidth + (columns - 1) * padding; - const gridHeight = Math.max(...columnHeights) - padding; // remove trailing padding + const gridWidth = Math.min(entities.length, maxColumns) * entityWidth + (Math.min(entities.length, maxColumns) - 1) * padding; + const gridHeight = (currentRow + 1) * (calculateEntityHeight(entities[0] || { Attributes: [] } as any, options.diagramType) + padding) - padding; return { positions, gridWidth, gridHeight, - columns, - rows: Math.ceil(entities.length / columns) // estimated rows + columns: Math.min(entities.length, maxColumns), + rows: currentRow + 1 }; }; From 40d0ceb962beaf0e3c4561121de0a3b86b378d2e Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 20:55:42 +0200 Subject: [PATCH 42/45] chore: fixed newly introduced ESLint errors and logs --- Website/components/diagram/DiagramView.tsx | 2 -- .../components/diagram/GridLayoutManager.ts | 18 +----------------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index b7dd217..32d22f7 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -5,8 +5,6 @@ import { dia, util } from '@joint/core' import { Groups } from "../../generated/Data" import { SquareElement } from '@/components/diagram/elements/SquareElement'; import { TextElement } from '@/components/diagram/elements/TextElement'; -import { Separator } from '@/components/ui/separator'; -import { Loading } from '@/components/ui/loading'; import { DiagramCanvas } from '@/components/diagram/DiagramCanvas'; import { ZoomCoordinateIndicator } from '@/components/diagram/ZoomCoordinateIndicator'; import { EntityActionsPane, LinkPropertiesPane, LinkProperties } from '@/components/diagram/panes'; diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts index afa2c79..cdf64e2 100644 --- a/Website/components/diagram/GridLayoutManager.ts +++ b/Website/components/diagram/GridLayoutManager.ts @@ -72,15 +72,11 @@ export const calculateGridLayout = ( let startColumn = 0; let startRow = 0; - console.log('🔍 GridLayout: existingPositions received:', existingPositions); - 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)); - console.log('📏 GridLayout: maxX:', maxX, 'maxY:', maxY); - // Start new entities to the right of existing ones, or on the next row startColumn = Math.floor((maxX + padding - margin) / (entityWidth + padding)); if (startColumn * (entityWidth + padding) + margin + entityWidth > containerWidth) { @@ -88,10 +84,6 @@ export const calculateGridLayout = ( startColumn = 0; startRow = Math.floor((maxY + padding - margin) / 200); // Approximate row height } - - console.log('📍 GridLayout: calculated startColumn:', startColumn, 'startRow:', startRow); - } else { - console.log('📍 GridLayout: no existing positions, starting from origin'); } // Determine how many columns can fit @@ -102,8 +94,6 @@ export const calculateGridLayout = ( let currentColumn = startColumn; let currentRow = startRow; - console.log('📐 GridLayout: maxColumns:', maxColumns, 'starting at column:', currentColumn, 'row:', currentRow); - for (let i = 0; i < entities.length; i++) { const entity = entities[i]; const height = calculateEntityHeight(entity, options.diagramType); @@ -130,11 +120,8 @@ export const calculateGridLayout = ( ) : false; if (!isOccupied) { - console.log(`📍 GridLayout: placing ${entity.SchemaName} at column ${currentColumn}, row ${currentRow} (${x}, ${y})`); positions.push({ x, y }); foundValidPosition = true; - } else { - console.log(`🚫 GridLayout: position (${x}, ${y}) occupied by existing entity, trying next...`); } // Move to next position @@ -143,7 +130,6 @@ export const calculateGridLayout = ( } if (!foundValidPosition) { - console.warn(`⚠️ GridLayout: Could not find valid position for ${entity.SchemaName}, using fallback`); // Fallback: place at calculated position anyway (should not happen with enough attempts) const x = margin + currentColumn * (entityWidth + padding); const y = margin + currentRow * (height + padding); @@ -151,11 +137,9 @@ export const calculateGridLayout = ( currentColumn++; } } - - console.log('✅ GridLayout: final positions:', positions); const gridWidth = Math.min(entities.length, maxColumns) * entityWidth + (Math.min(entities.length, maxColumns) - 1) * padding; - const gridHeight = (currentRow + 1) * (calculateEntityHeight(entities[0] || { Attributes: [] } as any, options.diagramType) + padding) - padding; + const gridHeight = (currentRow + 1) * (calculateEntityHeight(entities[0] || { Attributes: [] }, options.diagramType) + padding) - padding; return { positions, From 9430078001e4e29871c8488ce8e91aa573a165d0 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 21:20:44 +0200 Subject: [PATCH 43/45] fix: do recalculation on diagramtype swap --- Website/components/diagram/DiagramView.tsx | 35 +++++++++++---- .../components/diagram/GridLayoutManager.ts | 45 +++++++++++++------ 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx index 32d22f7..81f057e 100644 --- a/Website/components/diagram/DiagramView.tsx +++ b/Website/components/diagram/DiagramView.tsx @@ -43,6 +43,9 @@ const DiagramContent = () => { // 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) => { @@ -122,6 +125,15 @@ const DiagramContent = () => { return; } + // Check if diagram type has changed and clear all positions if so + let diagramTypeChanged = false; + if (previousDiagramTypeRef.current !== diagramType) { + console.log(`🔄 Diagram type changed from ${previousDiagramTypeRef.current} to ${diagramType}, clearing all entity positions`); + entityPositionsRef.current.clear(); + previousDiagramTypeRef.current = diagramType; + diagramTypeChanged = true; + } + // Set loading state when starting diagram creation setIsLoading(true); @@ -153,15 +165,20 @@ const DiagramContent = () => { })); // Update persistent position tracking with current positions - console.log('🔍 Before update - entityPositionsRef has:', Array.from(entityPositionsRef.current.keys())); - existingEntities.forEach(element => { - const entityData = element.get('data'); - if (entityData?.entity?.SchemaName) { - const position = element.position(); - entityPositionsRef.current.set(entityData.entity.SchemaName, position); - console.log(`📍 Updated position for ${entityData.entity.SchemaName}:`, position); - } - }); + // Skip this if diagram type changed to ensure all entities are treated as new + if (!diagramTypeChanged) { + console.log('🔍 Before update - entityPositionsRef has:', Array.from(entityPositionsRef.current.keys())); + existingEntities.forEach(element => { + const entityData = element.get('data'); + if (entityData?.entity?.SchemaName) { + const position = element.position(); + entityPositionsRef.current.set(entityData.entity.SchemaName, position); + console.log(`📍 Updated position for ${entityData.entity.SchemaName}:`, position); + } + }); + } else { + console.log('🔄 Skipping position update due to diagram type change'); + } // Clean up position tracking for entities that are no longer in currentEntities const currentEntityNames = new Set(currentEntities.map(e => e.SchemaName)); diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts index cdf64e2..25329e1 100644 --- a/Website/components/diagram/GridLayoutManager.ts +++ b/Website/components/diagram/GridLayoutManager.ts @@ -77,17 +77,22 @@ export const calculateGridLayout = ( 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) / (entityWidth + padding)); - if (startColumn * (entityWidth + padding) + margin + entityWidth > containerWidth) { + 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) / 200); // Approximate row height + startRow = Math.floor((maxY + padding - margin) / (sampleDimensions.height + padding)); } } - // Determine how many columns can fit - const maxColumns = Math.max(1, Math.floor((containerWidth - margin * 2 + padding) / (entityWidth + 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[] = []; @@ -96,7 +101,9 @@ export const calculateGridLayout = ( for (let i = 0; i < entities.length; i++) { const entity = entities[i]; - const height = calculateEntityHeight(entity, options.diagramType); + 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; @@ -110,14 +117,23 @@ export const calculateGridLayout = ( currentRow++; } - const x = margin + currentColumn * (entityWidth + padding); + 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 => - Math.abs(pos.x - x) < entityWidth + padding/2 && - Math.abs(pos.y - y) < height + padding/2 - ) : false; + 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 }); @@ -131,15 +147,16 @@ export const calculateGridLayout = ( if (!foundValidPosition) { // Fallback: place at calculated position anyway (should not happen with enough attempts) - const x = margin + currentColumn * (entityWidth + padding); + const x = margin + currentColumn * (width + padding); const y = margin + currentRow * (height + padding); positions.push({ x, y }); currentColumn++; } } - const gridWidth = Math.min(entities.length, maxColumns) * entityWidth + (Math.min(entities.length, maxColumns) - 1) * padding; - const gridHeight = (currentRow + 1) * (calculateEntityHeight(entities[0] || { Attributes: [] }, options.diagramType) + padding) - padding; + 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, From 3f255b50ef4b276dda486c5b70e7dd5c4b054dd7 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 21:23:05 +0200 Subject: [PATCH 44/45] chroe: lint error... --- Website/components/diagram/GridLayoutManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts index 25329e1..b23e3e2 100644 --- a/Website/components/diagram/GridLayoutManager.ts +++ b/Website/components/diagram/GridLayoutManager.ts @@ -56,7 +56,7 @@ export const calculateGridLayout = ( options: GridLayoutOptions, existingPositions?: { x: number; y: number; width: number; height: number }[] ): GridLayoutResult => { - const { containerWidth, entityWidth, padding, margin } = options; + const { containerWidth, padding, margin } = options; if (entities.length === 0) { return { From cc4d3094467c19d1ef13dd7694ec1347d62dfb3a Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Sat, 9 Aug 2025 21:43:58 +0200 Subject: [PATCH 45/45] chore: copilot suggestions --- .../components/diagram/elements/SquareElement.ts | 2 +- .../components/diagram/elements/TextElement.ts | 15 +++++++++++++-- .../components/diagram/panes/AddEntityPane.tsx | 7 ------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Website/components/diagram/elements/SquareElement.ts b/Website/components/diagram/elements/SquareElement.ts index 8833278..e17f391 100644 --- a/Website/components/diagram/elements/SquareElement.ts +++ b/Website/components/diagram/elements/SquareElement.ts @@ -270,7 +270,7 @@ export class SquareElement extends dia.Element { } // Alternative approach: check the SVG element class or tag - const tagName = target.tagName.toLowerCase(); + const tagName = target.tagName?.toLowerCase(); if (tagName === 'rect') { // Check if this rect is one of our resize handles const parent = target.parentElement; diff --git a/Website/components/diagram/elements/TextElement.ts b/Website/components/diagram/elements/TextElement.ts index 5bc2af8..64eb78e 100644 --- a/Website/components/diagram/elements/TextElement.ts +++ b/Website/components/diagram/elements/TextElement.ts @@ -1,5 +1,17 @@ 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; @@ -104,8 +116,7 @@ export class TextElement extends shapes.standard.Rectangle { } private adjustSizeToText(data: TextElementData) { - // Calculate approximate text width (rough estimation) - const charWidth = data.fontSize * 0.6; // Approximate character width + 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); diff --git a/Website/components/diagram/panes/AddEntityPane.tsx b/Website/components/diagram/panes/AddEntityPane.tsx index 73b06be..c96e38d 100644 --- a/Website/components/diagram/panes/AddEntityPane.tsx +++ b/Website/components/diagram/panes/AddEntityPane.tsx @@ -18,13 +18,6 @@ export interface AddEntityPaneProps { currentEntities: EntityType[]; } -export interface AddEntityPaneProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onAddEntity: (entity: EntityType, selectedAttributes?: string[]) => void; - currentEntities: EntityType[]; -} - export const AddEntityPane: React.FC = ({ isOpen, onOpenChange,