From 2bd9ca25b2c7b1f053ff4fdc8c5cfc1b0e65901d Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Wed, 10 Sep 2025 16:37:33 +0300 Subject: [PATCH 001/156] fix: update deps vulns (#909) --- package-lock.json | 249 +++++++++--------- .../button/button-with-alternatives.tsx | 65 +++-- src/components/select-box/select-box.tsx | 11 + 3 files changed, 176 insertions(+), 149 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b0b63c5c..790baabd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -586,15 +586,15 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -738,18 +738,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -766,26 +766,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", - "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", - "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.7" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -827,27 +827,24 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", - "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -883,13 +880,13 @@ } }, "node_modules/@babel/types": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", - "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1370,9 +1367,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -1417,9 +1414,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1431,10 +1428,20 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1445,9 +1452,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1482,13 +1489,16 @@ } }, "node_modules/@eslint/js": { - "version": "9.19.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", - "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -1502,13 +1512,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", - "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -1606,9 +1616,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4276,12 +4286,6 @@ "@types/deep-eql": "*" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "license": "MIT" - }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -4553,9 +4557,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4961,9 +4965,9 @@ } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5486,9 +5490,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -6578,22 +6582,23 @@ } }, "node_modules/eslint": { - "version": "9.19.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", - "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.10.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.19.0", - "@eslint/plugin-kit": "^0.2.5", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -6601,9 +6606,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -6812,9 +6817,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6842,9 +6847,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -6862,15 +6867,15 @@ "peer": true }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6880,9 +6885,9 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -7365,9 +7370,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -9606,15 +9611,13 @@ } }, "node_modules/react-router": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.5.tgz", - "integrity": "sha512-8BUF+hZEU4/z/JD201yK6S+UYhsf58bzYIDq2NS1iGpwxSXDu7F+DeGSkIXMFBuHZB21FSiCzEcUb18cQNdRkA==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", + "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.6.0", "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0", - "turbo-stream": "2.4.0" + "set-cookie-parser": "^2.6.0" }, "engines": { "node": ">=20.0.0" @@ -9630,12 +9633,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.5.tgz", - "integrity": "sha512-/4f9+up0Qv92D3bB8iN5P1s3oHAepSGa9h5k6tpTFlixTTskJZwKGhJ6vRJ277tLD1zuaZTt95hyGWV1Z37csQ==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz", + "integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==", "license": "MIT", "dependencies": { - "react-router": "7.1.5" + "react-router": "7.8.2" }, "engines": { "node": ">=20.0.0" @@ -9766,12 +9769,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -11032,12 +11029,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/turbo-stream": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", - "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", - "license": "ISC" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11319,9 +11310,9 @@ } }, "node_modules/vite": { - "version": "5.4.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", - "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/components/button/button-with-alternatives.tsx b/src/components/button/button-with-alternatives.tsx index ac1da6db0..61c0023de 100644 --- a/src/components/button/button-with-alternatives.tsx +++ b/src/components/button/button-with-alternatives.tsx @@ -11,18 +11,26 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/dropdown-menu/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/tooltip/tooltip'; + +export interface ButtonAlternative { + label: string; + onClick: () => void; + disabled?: boolean; + icon?: React.ReactNode; + className?: string; + tooltip?: string; +} export interface ButtonWithAlternativesProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; - alternatives: Array<{ - label: string; - onClick: () => void; - disabled?: boolean; - icon?: React.ReactNode; - className?: string; - }>; + alternatives: Array; dropdownTriggerClassName?: string; chevronDownIconClassName?: string; } @@ -87,19 +95,36 @@ const ButtonWithAlternatives = React.forwardRef< - {alternatives.map((alternative, index) => ( - - - {alternative.label} - {alternative.icon} - - - ))} + {alternatives.map((alternative, index) => { + const menuItem = ( + + + {alternative.label} + {alternative.icon} + + + ); + + if (alternative.tooltip) { + return ( + + + {menuItem} + + + {alternative.tooltip} + + + ); + } + + return menuItem; + })} ) : null} diff --git a/src/components/select-box/select-box.tsx b/src/components/select-box/select-box.tsx index 642fd959b..44ee1d81c 100644 --- a/src/components/select-box/select-box.tsx +++ b/src/components/select-box/select-box.tsx @@ -27,6 +27,7 @@ export interface SelectBoxOption { regex?: string; extractRegex?: RegExp; group?: string; + icon?: React.ReactNode; } export interface SelectBoxProps { @@ -54,6 +55,7 @@ export interface SelectBoxProps { onOpenChange?: (open: boolean) => void; popoverClassName?: string; readonly?: boolean; + footerButtons?: React.ReactNode; } export const SelectBox = React.forwardRef( @@ -80,6 +82,7 @@ export const SelectBox = React.forwardRef( onOpenChange: setOpen, popoverClassName, readonly, + footerButtons, }, ref ) => { @@ -254,6 +257,11 @@ export const SelectBox = React.forwardRef( )}
+ {option.icon ? ( + + {option.icon} + + ) : null} {isRegexMatch ? searchTerm : option.label} {!isRegexMatch && optionSuffix @@ -447,6 +455,9 @@ export const SelectBox = React.forwardRef(
+ {footerButtons ? ( +
{footerButtons}
+ ) : null} ); From 5e81c1848aaa911990e1e881d62525f5254d6d34 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Wed, 10 Sep 2025 22:24:05 +0300 Subject: [PATCH 002/156] fix(dbml): export array fields without quotes (#911) --- .../dbml/dbml-export/__tests__/cases/3.dbml | 8 ++++++ .../dbml/dbml-export/__tests__/cases/3.json | 1 + .../__tests__/export-sql-dbml-cases.test.ts | 20 +++++++++++++++ src/lib/dbml/dbml-export/dbml-export.ts | 25 +++++++++++++------ 4 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/3.dbml create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/3.json diff --git a/src/lib/dbml/dbml-export/__tests__/cases/3.dbml b/src/lib/dbml/dbml-export/__tests__/cases/3.dbml new file mode 100644 index 000000000..d5fe9e9e6 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/3.dbml @@ -0,0 +1,8 @@ +Table "public"."guy_table" { + "id" integer [pk, not null] + "created_at" timestamp [not null] + "column3" text + "arrayfield" text[] + "field_5" "character varying" + "field_6" "character varying(100)" +} diff --git a/src/lib/dbml/dbml-export/__tests__/cases/3.json b/src/lib/dbml/dbml-export/__tests__/cases/3.json new file mode 100644 index 000000000..09b57ccc6 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/3.json @@ -0,0 +1 @@ +{"id":"mqqwkkod7trl","name":"guy-db","databaseType":"postgresql","createdAt":"2025-09-10T18:45:32.817Z","updatedAt":"2025-09-10T19:15:21.682Z","tables":[{"id":"g2hv9mlo3qbyjnxdc44j1zxl2","name":"guy_table","schema":"public","x":100,"y":300,"fields":[{"id":"qdqgzmtxsi84ujfuktsvjuop8","name":"id","type":{"id":"integer","name":"integer"},"primaryKey":true,"unique":true,"nullable":false,"createdAt":1757529932816},{"id":"wsys99f86679ch6fbjryw0egr","name":"created_at","type":{"id":"timestamp_without_time_zone","name":"timestamp without time zone"},"primaryKey":false,"unique":false,"nullable":false,"createdAt":1757529932816},{"id":"ro39cba7sd290k90qjgzib8pi","name":"column3","type":{"id":"text","name":"text"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1757529932816},{"id":"6cntbu2orwk7kxlg0rcduqgbo","name":"arrayfield","type":{"id":"array","name":"array"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1757529932816},{"id":"7cz0ybdoov2m3wbgm9tlzatz0","name":"field_5","type":{"id":"character_varying","name":"character varying"},"unique":false,"nullable":true,"primaryKey":false,"createdAt":1757531685981},{"id":"zzwlyvqzz93oh0vv8f8qob103","name":"field_6","type":{"id":"character_varying","name":"character varying"},"unique":false,"nullable":true,"primaryKey":false,"createdAt":1757531713961,"characterMaximumLength":"100"}],"indexes":[{"id":"r0w71lnbnje2j9cz1t9j64rya","name":"guy_table_pkey","unique":true,"fieldIds":["qdqgzmtxsi84ujfuktsvjuop8"],"createdAt":1757529932816,"isPrimaryKey":true}],"color":"#8eb7ff","isView":false,"isMaterializedView":false,"createdAt":1757529932816,"diagramId":"mqqwkkod7trl"}],"relationships":[],"dependencies":[],"areas":[],"customTypes":[]} \ No newline at end of file diff --git a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts index 356a032e0..b4f893176 100644 --- a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts +++ b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts @@ -44,4 +44,24 @@ describe('DBML Export - Diagram Case 1 Tests', () => { // Compare the generated DBML with the expected DBML expect(generatedDBML).toBe(expectedDBML); }); + + it('should handle case 3 diagram', { timeout: 30000 }, async () => { + // Read the JSON file + const jsonPath = path.join(__dirname, 'cases', '3.json'); + const jsonContent = fs.readFileSync(jsonPath, 'utf-8'); + + // Parse the JSON and convert to diagram + const diagram = diagramFromJSONInput(jsonContent); + + // Generate DBML from the diagram + const result = generateDBMLFromDiagram(diagram); + const generatedDBML = result.standardDbml; + + // Read the expected DBML file + const dbmlPath = path.join(__dirname, 'cases', '3.dbml'); + const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); + + // Compare the generated DBML with the expected DBML + expect(generatedDBML).toBe(expectedDBML); + }); }); diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index b0049583d..0c0a8935f 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -596,6 +596,13 @@ const normalizeCharTypeFormat = (dbml: string): string => { .replace(/character \(([0-9]+)\)/g, 'character($1)'); }; +// Fix array types that are incorrectly quoted by DBML importer +const fixArrayTypes = (dbml: string): string => { + // Remove quotes around array types like "text[]" -> text[] + // Matches patterns like: "fieldname" "type[]" and replaces with "fieldname" type[] + return dbml.replace(/(\s+"[^"]+"\s+)"([^"\s]+\[\])"/g, '$1$2'); +}; + // Fix table definitions with incorrect bracket syntax const fixTableBracketSyntax = (dbml: string): string => { // Fix patterns like Table [schema].[table] to Table "schema"."table" @@ -985,12 +992,14 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { ); } - standard = normalizeCharTypeFormat( - fixMultilineTableNames( - fixTableBracketSyntax( - importer.import( - baseScript, - databaseTypeToImportFormat(diagram.databaseType) + standard = fixArrayTypes( + normalizeCharTypeFormat( + fixMultilineTableNames( + fixTableBracketSyntax( + importer.import( + baseScript, + databaseTypeToImportFormat(diagram.databaseType) + ) ) ) ) @@ -1007,7 +1016,9 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { standard = enumsDBML + '\n\n' + standard; } - inline = normalizeCharTypeFormat(convertToInlineRefs(standard)); + inline = fixArrayTypes( + normalizeCharTypeFormat(convertToInlineRefs(standard)) + ); // Clean up excessive empty lines in both outputs standard = standard.replace(/\n\s*\n\s*\n/g, '\n\n'); From 1a6688e85ee624d89c7aee4c6c9b60cb4693aa96 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Thu, 11 Sep 2025 12:32:58 +0300 Subject: [PATCH 003/156] alignment (#912) --- src/lib/domain/diff/diff.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/lib/domain/diff/diff.ts b/src/lib/domain/diff/diff.ts index a93b6d474..6129f523e 100644 --- a/src/lib/domain/diff/diff.ts +++ b/src/lib/domain/diff/diff.ts @@ -57,3 +57,40 @@ export type DiffObject< | FieldDiff['object'] | IndexDiff['object'] | RelationshipDiff['object']; + +type ExtractDiffKind = T extends { object: infer O; type: infer Type } + ? T extends { attribute: infer A } + ? { object: O; type: Type; attribute: A } + : { object: O; type: Type } + : never; + +export type DiffKind< + TTable = DBTable, + TField = DBField, + TIndex = DBIndex, + TRelationship = DBRelationship, +> = ExtractDiffKind>; + +export const isDiffOfKind = < + TTable = DBTable, + TField = DBField, + TIndex = DBIndex, + TRelationship = DBRelationship, +>( + diff: ChartDBDiff, + kind: DiffKind +): boolean => { + if ('attribute' in kind) { + return ( + diff.object === kind.object && + diff.type === kind.type && + diff.attribute === kind.attribute + ); + } + + if ('attribute' in diff) { + return false; + } + + return diff.object === kind.object && diff.type === kind.type; +}; From 8954d893bbfee45bb311380115fb14ebbf3a3133 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 14 Sep 2025 20:42:01 +0300 Subject: [PATCH 004/156] feat: add quick table mode on canvas (#915) * fix: add quick table mode on canvas * fix * fix * fix --- src/components/color-picker/color-picker.tsx | 10 +- src/components/select-box/select-box.tsx | 18 +- src/context/canvas-context/canvas-context.tsx | 12 + .../canvas-context/canvas-provider.tsx | 6 + src/hooks/use-update-table-field.ts | 320 ++++++++++++++++++ src/hooks/use-update-table.ts | 42 +++ src/index.css | 3 + src/pages/editor-page/canvas/canvas.tsx | 16 +- .../table-edit-mode/table-edit-mode-field.tsx | 180 ++++++++++ .../table-edit-mode/table-edit-mode.tsx | 201 +++++++++++ .../table-edit-mode/table-field-toggle.tsx | 18 + .../canvas/table-node/table-node-field.tsx | 303 +++++++---------- .../canvas/table-node/table-node.tsx | 206 +++++------ .../table-field/table-field.tsx | 232 ++----------- 14 files changed, 1082 insertions(+), 485 deletions(-) create mode 100644 src/hooks/use-update-table-field.ts create mode 100644 src/hooks/use-update-table.ts create mode 100644 src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx create mode 100644 src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx create mode 100644 src/pages/editor-page/canvas/table-node/table-edit-mode/table-field-toggle.tsx diff --git a/src/components/color-picker/color-picker.tsx b/src/components/color-picker/color-picker.tsx index bc02462b9..4aae0ba9e 100644 --- a/src/components/color-picker/color-picker.tsx +++ b/src/components/color-picker/color-picker.tsx @@ -11,12 +11,14 @@ export interface ColorPickerProps { color: string; onChange: (color: string) => void; disabled?: boolean; + popoverOnMouseDown?: (e: React.MouseEvent) => void; + popoverOnClick?: (e: React.MouseEvent) => void; } export const ColorPicker = React.forwardRef< React.ElementRef, ColorPickerProps ->(({ color, onChange, disabled }, ref) => { +>(({ color, onChange, disabled, popoverOnMouseDown, popoverOnClick }, ref) => { return ( - +
{colorOptions.map((option) => (
void; + commandOnClick?: (e: React.MouseEvent) => void; } export const SelectBox = React.forwardRef( @@ -83,6 +85,8 @@ export const SelectBox = React.forwardRef( popoverClassName, readonly, footerButtons, + commandOnMouseDown, + commandOnClick, }, ref ) => { @@ -243,6 +247,8 @@ export const SelectBox = React.forwardRef( matches?.map((match) => match?.toString()) ) } + onMouseDown={commandOnMouseDown} + onClick={commandOnClick} > {multiple && (
( ); }, - [value, multiple, searchTerm, handleSelect, optionSuffix] + [ + value, + multiple, + searchTerm, + handleSelect, + optionSuffix, + commandOnClick, + commandOnMouseDown, + ] ); return ( @@ -366,6 +380,8 @@ export const SelectBox = React.forwardRef( popoverClassName )} align="center" + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > { diff --git a/src/context/canvas-context/canvas-context.tsx b/src/context/canvas-context/canvas-context.tsx index 3f4f86615..7ec899aa5 100644 --- a/src/context/canvas-context/canvas-context.tsx +++ b/src/context/canvas-context/canvas-context.tsx @@ -14,6 +14,16 @@ export interface CanvasContext { overlapGraph: Graph; setShowFilter: React.Dispatch>; showFilter: boolean; + editTableModeTable: { + tableId: string; + fieldId?: string; + } | null; + setEditTableModeTable: React.Dispatch< + React.SetStateAction<{ + tableId: string; + fieldId?: string; + } | null> + >; } export const canvasContext = createContext({ @@ -23,4 +33,6 @@ export const canvasContext = createContext({ overlapGraph: createGraph(), setShowFilter: emptyFn, showFilter: false, + editTableModeTable: null, + setEditTableModeTable: emptyFn, }); diff --git a/src/context/canvas-context/canvas-provider.tsx b/src/context/canvas-context/canvas-provider.tsx index 8f166d27c..62cae45b0 100644 --- a/src/context/canvas-context/canvas-provider.tsx +++ b/src/context/canvas-context/canvas-provider.tsx @@ -33,6 +33,10 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => { const { fitView } = useReactFlow(); const [overlapGraph, setOverlapGraph] = useState>(createGraph()); + const [editTableModeTable, setEditTableModeTable] = useState<{ + tableId: string; + fieldId?: string; + } | null>(null); const [showFilter, setShowFilter] = useState(false); const diagramIdActiveFilterRef = useRef(); @@ -127,6 +131,8 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => { overlapGraph, setShowFilter, showFilter, + editTableModeTable, + setEditTableModeTable, }} > {children} diff --git a/src/hooks/use-update-table-field.ts b/src/hooks/use-update-table-field.ts new file mode 100644 index 000000000..f1282b29f --- /dev/null +++ b/src/hooks/use-update-table-field.ts @@ -0,0 +1,320 @@ +import { useCallback, useMemo, useState, useEffect } from 'react'; +import { useChartDB } from './use-chartdb'; +import { useDebounce } from './use-debounce-v2'; +import type { DBField, DBTable } from '@/lib/domain'; +import type { + SelectBoxOption, + SelectBoxProps, +} from '@/components/select-box/select-box'; +import { + dataTypeDataToDataType, + sortedDataTypeMap, +} from '@/lib/data/data-types/data-types'; +import { generateDBFieldSuffix } from '@/lib/domain/db-field'; +import type { DataTypeData } from '@/lib/data/data-types/data-types'; + +const generateFieldRegexPatterns = ( + dataType: DataTypeData +): { + regex?: string; + extractRegex?: RegExp; +} => { + if (!dataType.fieldAttributes) { + return { regex: undefined, extractRegex: undefined }; + } + + const typeName = dataType.name; + const fieldAttributes = dataType.fieldAttributes; + + if (fieldAttributes.hasCharMaxLength) { + if (fieldAttributes.hasCharMaxLengthOption) { + return { + regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)$`, + extractRegex: /\((\d+|max)\)/i, + }; + } + return { + regex: `^${typeName}\\(\\d+\\)$`, + extractRegex: /\((\d+)\)/, + }; + } + + if (fieldAttributes.precision && fieldAttributes.scale) { + return { + regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`, + extractRegex: new RegExp( + `${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)` + ), + }; + } + + if (fieldAttributes.precision) { + return { + regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`, + extractRegex: /\((\d+)\)/, + }; + } + + return { regex: undefined, extractRegex: undefined }; +}; + +export const useUpdateTableField = ( + table: DBTable, + field: DBField, + customUpdateField?: (attrs: Partial) => void +) => { + const { + databaseType, + customTypes, + updateField: chartDBUpdateField, + removeField: chartDBRemoveField, + } = useChartDB(); + + // Local state for responsive UI + const [localFieldName, setLocalFieldName] = useState(field.name); + const [localNullable, setLocalNullable] = useState(field.nullable); + const [localPrimaryKey, setLocalPrimaryKey] = useState(field.primaryKey); + + // Update local state when field properties change externally + useEffect(() => { + setLocalFieldName(field.name); + setLocalNullable(field.nullable); + setLocalPrimaryKey(field.primaryKey); + }, [field.name, field.nullable, field.primaryKey]); + + // Use custom updateField if provided, otherwise use the chartDB one + const updateField = useMemo( + () => + customUpdateField + ? ( + _tableId: string, + _fieldId: string, + attrs: Partial + ) => customUpdateField(attrs) + : chartDBUpdateField, + [customUpdateField, chartDBUpdateField] + ); + + // Calculate primary key fields for validation + const primaryKeyFields = useMemo(() => { + return table.fields.filter((f) => f.primaryKey); + }, [table.fields]); + + const primaryKeyCount = useMemo( + () => primaryKeyFields.length, + [primaryKeyFields.length] + ); + + // Generate data type options for select box + const dataFieldOptions = useMemo(() => { + const standardTypes: SelectBoxOption[] = sortedDataTypeMap[ + databaseType + ].map((type) => { + const regexPatterns = generateFieldRegexPatterns(type); + + return { + label: type.name, + value: type.id, + regex: regexPatterns.regex, + extractRegex: regexPatterns.extractRegex, + group: customTypes?.length ? 'Standard Types' : undefined, + }; + }); + + if (!customTypes?.length) { + return standardTypes; + } + + // Add custom types as options + const customTypeOptions: SelectBoxOption[] = customTypes.map( + (type) => ({ + label: type.name, + value: type.name, + description: + type.kind === 'enum' ? `${type.values?.join(' | ')}` : '', + group: 'Custom Types', + }) + ); + + return [...standardTypes, ...customTypeOptions]; + }, [databaseType, customTypes]); + + // Handle data type change + const handleDataTypeChange = useCallback< + NonNullable + >( + (value, regexMatches) => { + const dataType = sortedDataTypeMap[databaseType].find( + (v) => v.id === value + ) ?? { + id: value as string, + name: value as string, + }; + + let characterMaximumLength: string | undefined = undefined; + let precision: number | undefined = undefined; + let scale: number | undefined = undefined; + + if (regexMatches?.length) { + if (dataType?.fieldAttributes?.hasCharMaxLength) { + characterMaximumLength = regexMatches[1]?.toLowerCase(); + } else if ( + dataType?.fieldAttributes?.precision && + dataType?.fieldAttributes?.scale + ) { + precision = parseInt(regexMatches[1]); + scale = regexMatches[2] + ? parseInt(regexMatches[2]) + : undefined; + } else if (dataType?.fieldAttributes?.precision) { + precision = parseInt(regexMatches[1]); + } + } else { + if ( + dataType?.fieldAttributes?.hasCharMaxLength && + field.characterMaximumLength + ) { + characterMaximumLength = field.characterMaximumLength; + } + + if (dataType?.fieldAttributes?.precision && field.precision) { + precision = field.precision; + } + + if (dataType?.fieldAttributes?.scale && field.scale) { + scale = field.scale; + } + } + + updateField(table.id, field.id, { + characterMaximumLength, + precision, + scale, + increment: undefined, + default: undefined, + type: dataTypeDataToDataType( + dataType ?? { + id: value as string, + name: value as string, + } + ), + }); + }, + [ + updateField, + databaseType, + field.characterMaximumLength, + field.precision, + field.scale, + field.id, + table.id, + ] + ); + + // Debounced update for field name + const debouncedNameUpdate = useDebounce( + useCallback( + (value: string) => { + if (value.trim() !== field.name) { + updateField(table.id, field.id, { name: value }); + } + }, + [updateField, table.id, field.id, field.name] + ), + 300 // 300ms debounce for text input + ); + + // Debounced update for nullable toggle + const debouncedNullableUpdate = useDebounce( + useCallback( + (value: boolean) => { + updateField(table.id, field.id, { nullable: value }); + }, + [updateField, table.id, field.id] + ), + 100 // 100ms debounce for toggle + ); + + // Debounced update for primary key toggle + const debouncedPrimaryKeyUpdate = useDebounce( + useCallback( + (value: boolean, primaryKeyCount: number) => { + if (value) { + // When setting as primary key + const updates: Partial = { + primaryKey: true, + }; + // Only auto-set unique if this will be the only primary key + if (primaryKeyCount === 0) { + updates.unique = true; + } + updateField(table.id, field.id, updates); + } else { + // When removing primary key + updateField(table.id, field.id, { + primaryKey: false, + }); + } + }, + [updateField, table.id, field.id] + ), + 100 // 100ms debounce for toggle + ); + + // Handle primary key toggle with optimistic update + const handlePrimaryKeyToggle = useCallback( + (value: boolean) => { + setLocalPrimaryKey(value); + debouncedPrimaryKeyUpdate(value, primaryKeyCount); + }, + [primaryKeyCount, debouncedPrimaryKeyUpdate] + ); + + // Handle nullable toggle with optimistic update + const handleNullableToggle = useCallback( + (value: boolean) => { + setLocalNullable(value); + debouncedNullableUpdate(value); + }, + [debouncedNullableUpdate] + ); + + // Handle name change with optimistic update + const handleNameChange = useCallback( + (value: string) => { + setLocalFieldName(value); + debouncedNameUpdate(value); + }, + [debouncedNameUpdate] + ); + + // Utility function to generate field suffix for display + const generateFieldSuffix = useCallback( + (typeId?: string) => { + return generateDBFieldSuffix(field, { + databaseType, + forceExtended: true, + typeId, + }); + }, + [field, databaseType] + ); + + const removeField = useCallback(() => { + chartDBRemoveField(table.id, field.id); + }, [chartDBRemoveField, table.id, field.id]); + + return { + dataFieldOptions, + handleDataTypeChange, + handlePrimaryKeyToggle, + handleNullableToggle, + handleNameChange, + generateFieldSuffix, + primaryKeyCount, + fieldName: localFieldName, + nullable: localNullable, + primaryKey: localPrimaryKey, + removeField, + }; +}; diff --git a/src/hooks/use-update-table.ts b/src/hooks/use-update-table.ts new file mode 100644 index 000000000..e160c0c0f --- /dev/null +++ b/src/hooks/use-update-table.ts @@ -0,0 +1,42 @@ +import { useCallback, useState, useEffect } from 'react'; +import { useChartDB } from './use-chartdb'; +import { useDebounce } from './use-debounce-v2'; +import type { DBTable } from '@/lib/domain'; + +// Hook for updating table properties with debouncing for performance +export const useUpdateTable = (table: DBTable) => { + const { updateTable: chartDBUpdateTable } = useChartDB(); + const [localTableName, setLocalTableName] = useState(table.name); + + // Debounced update function + const debouncedUpdate = useDebounce( + useCallback( + (value: string) => { + if (value.trim() && value.trim() !== table.name) { + chartDBUpdateTable(table.id, { name: value.trim() }); + } + }, + [chartDBUpdateTable, table.id, table.name] + ), + 1000 // 1000ms debounce + ); + + // Update local state immediately for responsive UI + const handleTableNameChange = useCallback( + (value: string) => { + setLocalTableName(value); + debouncedUpdate(value); + }, + [debouncedUpdate] + ); + + // Update local state when table name changes externally + useEffect(() => { + setLocalTableName(table.name); + }, [table.name]); + + return { + tableName: localTableName, + handleTableNameChange, + }; +}; diff --git a/src/index.css b/src/index.css index fae1a2eb8..bf5e471fe 100644 --- a/src/index.css +++ b/src/index.css @@ -18,4 +18,7 @@ .marker-definitions { } + + .nodrag { + } } diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index aaf2ef7e6..6755faf7b 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -89,6 +89,7 @@ import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-f import { filterTable } from '@/lib/domain/diagram-filter/filter'; import { defaultSchemas } from '@/lib/data/default-schemas'; import { useDiff } from '@/context/diff-context/use-diff'; +import { useClickAway } from 'react-use'; const HIGHLIGHTED_EDGE_Z_INDEX = 1; const DEFAULT_EDGE_Z_INDEX = 0; @@ -244,6 +245,7 @@ export const Canvas: React.FC = ({ initialTables }) => { overlapGraph, showFilter, setShowFilter, + setEditTableModeTable, } = useCanvas(); const { filter, loading: filterLoading } = useDiagramFilter(); const { checkIfNewTable } = useDiff(); @@ -1213,6 +1215,13 @@ export const Canvas: React.FC = ({ initialTables }) => { setTimeout(() => setHighlightOverlappingTables(false), 600); }, []); + const containerRef = useRef(null); + const exitEditTableMode = useCallback( + () => setEditTableModeTable(null), + [setEditTableModeTable] + ); + useClickAway(containerRef, exitEditTableMode); + const shiftPressed = useKeyPress('Shift'); const operatingSystem = getOperatingSystem(); @@ -1230,7 +1239,11 @@ export const Canvas: React.FC = ({ initialTables }) => { return ( -
+
= ({ initialTables }) => { panOnScroll={scrollAction === 'pan'} snapToGrid={shiftPressed || snapToGridEnabled} snapGrid={[20, 20]} + onPaneClick={exitEditTableMode} > = React.memo( + ({ table, field, focused = false }) => { + const { t } = useTranslation(); + const [showHighlight, setShowHighlight] = React.useState(false); + + const { + dataFieldOptions, + handleDataTypeChange, + handlePrimaryKeyToggle, + handleNullableToggle, + handleNameChange, + generateFieldSuffix, + fieldName, + nullable, + primaryKey, + removeField, + } = useUpdateTableField(table, field); + + const inputRef = React.useRef(null); + + // Animate the highlight after mount if focused + useEffect(() => { + if (focused) { + const timer = setTimeout(() => { + setShowHighlight(true); + inputRef.current?.select(); + + setTimeout(() => { + setShowHighlight(false); + }, 2000); + }, 200); // Small delay for the animation to be noticeable + + return () => clearTimeout(timer); + } else { + setShowHighlight(false); + } + }, [focused]); + + return ( +
+
+ + + + + handleNameChange(e.target.value) + } + autoFocus={focused} + /> + + + {fieldName} + + + + + + generateFieldSuffix(option.value) + } + onChange={handleDataTypeChange} + emptyPlaceholder={t( + 'side_panel.tables_section.table.no_types_found' + )} + commandOnClick={(e) => e.stopPropagation()} + commandOnMouseDown={(e) => + e.stopPropagation() + } + /> + + + + {field.type.name} + {field.characterMaximumLength + ? `(${field.characterMaximumLength})` + : ''} + + +
+
+ + + + + N + + + + + {t('side_panel.tables_section.table.nullable')} + + + + + + + + + + + + {t('side_panel.tables_section.table.primary_key')} + + + + + + + + + + + + + {t( + 'side_panel.tables_section.table.field_actions.delete_field' + )} + + +
+
+ ); + } +); + +TableEditModeField.displayName = 'TableEditModeField'; diff --git a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx new file mode 100644 index 000000000..655b45905 --- /dev/null +++ b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx @@ -0,0 +1,201 @@ +import { Input } from '@/components/input/input'; +import type { DBTable } from '@/lib/domain'; +import { FileType2, X } from 'lucide-react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { TableEditModeField } from './table-edit-mode-field'; +import { cn } from '@/lib/utils'; +import { ScrollArea } from '@/components/scroll-area/scroll-area'; +import { Button } from '@/components/button/button'; +import { ColorPicker } from '@/components/color-picker/color-picker'; +import { Separator } from '@/components/separator/separator'; +import { useChartDB } from '@/hooks/use-chartdb'; +import { useUpdateTable } from '@/hooks/use-update-table'; +import { useTranslation } from 'react-i18next'; + +export interface TableEditModeProps { + table: DBTable; + color: string; + focusFieldId?: string; + onClose: () => void; +} + +export const TableEditMode: React.FC = React.memo( + ({ table, color, focusFieldId: focusFieldIdProp, onClose }) => { + const containerRef = useRef(null); + const scrollAreaRef = useRef(null); + const fieldRefs = useRef>(new Map()); + const [isVisible, setIsVisible] = useState(false); + const { createField, updateTable } = useChartDB(); + const { t } = useTranslation(); + const { tableName, handleTableNameChange } = useUpdateTable(table); + const [focusFieldId, setFocusFieldId] = useState( + focusFieldIdProp + ); + const inputRef = useRef(null); + + useEffect(() => { + setFocusFieldId(focusFieldIdProp); + if (!focusFieldIdProp) { + inputRef.current?.select(); + } + }, [focusFieldIdProp]); + + // Callback to store field refs + const setFieldRef = useCallback((fieldId: string) => { + return (element: HTMLDivElement | null) => { + if (element) { + fieldRefs.current.set(fieldId, element); + } else { + fieldRefs.current.delete(fieldId); + } + }; + }, []); + + useEffect(() => { + // Trigger animation after mount + requestAnimationFrame(() => { + setIsVisible(true); + }); + }, []); + + const scrollToFieldId = useCallback((fieldId: string) => { + const fieldElement = fieldRefs.current.get(fieldId); + if (fieldElement) { + fieldElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + }, []); + + // Scroll to focused field when component mounts + useEffect(() => { + if (focusFieldId) { + scrollToFieldId(focusFieldId); + } + }, [focusFieldId, scrollToFieldId]); + + // Handle wheel events: allow zoom to pass through, but handle scroll locally + useEffect(() => { + const handleWheel = (e: WheelEvent) => { + // If Ctrl or Cmd is pressed, it's a zoom gesture - let it pass through to canvas + if (e.ctrlKey || e.metaKey) { + return; + } + + // Otherwise, it's a scroll - stop propagation to prevent canvas panning + e.stopPropagation(); + }; + + const scrollArea = scrollAreaRef.current; + if (scrollArea) { + // Use passive: false to allow preventDefault if needed + scrollArea.addEventListener('wheel', handleWheel, { + passive: false, + }); + + return () => { + scrollArea.removeEventListener('wheel', handleWheel); + }; + } + }, []); + + const handleAddField = useCallback(async () => { + const field = await createField(table.id); + + if (field.id) { + setFocusFieldId(field.id); + } + }, [createField, table.id]); + + const handleColorChange = useCallback( + (newColor: string) => { + updateTable(table.id, { color: newColor }); + }, + [updateTable, table.id] + ); + + return ( +
e.stopPropagation()} + > +
+
+
+ e.stopPropagation()} + popoverOnClick={(e) => e.stopPropagation()} + /> + + handleTableNameChange(e.target.value) + } + /> +
+ +
+ + + {table.fields.map((field) => ( +
+ +
+ ))} +
+ + +
+ + + {table.fields.length}{' '} + {t('side_panel.tables_section.table.fields')} + +
+
+ ); + } +); + +TableEditMode.displayName = 'TableEditMode'; diff --git a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-field-toggle.tsx b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-field-toggle.tsx new file mode 100644 index 000000000..0e2d34db5 --- /dev/null +++ b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-field-toggle.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Toggle } from '@/components/toggle/toggle'; + +export const TableFieldToggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => { + return ( + + ); +}); + +TableFieldToggle.displayName = Toggle.displayName; diff --git a/src/pages/editor-page/canvas/table-node/table-node-field.tsx b/src/pages/editor-page/canvas/table-node/table-node-field.tsx index 4200c9fb1..814294577 100644 --- a/src/pages/editor-page/canvas/table-node/table-node-field.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node-field.tsx @@ -13,13 +13,12 @@ import { } from '@xyflow/react'; import { Button } from '@/components/button/button'; import { - Check, KeyRound, MessageCircleMore, SquareDot, SquareMinus, SquarePlus, - Trash2, + Pencil, } from 'lucide-react'; import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field'; import { useChartDB } from '@/hooks/use-chartdb'; @@ -29,14 +28,14 @@ import { TooltipContent, TooltipTrigger, } from '@/components/tooltip/tooltip'; -import { useClickAway, useKeyPressEvent } from 'react-use'; -import { Input } from '@/components/input/input'; import { useDiff } from '@/context/diff-context/use-diff'; import { useLocalConfig } from '@/hooks/use-local-config'; import { BOTTOM_SOURCE_HANDLE_ID_PREFIX, TOP_SOURCE_HANDLE_ID_PREFIX, } from './table-node-dependency-indicator'; +import { useCanvas } from '@/hooks/use-canvas'; +import { useLayout } from '@/hooks/use-layout'; export const LEFT_HANDLE_ID_PREFIX = 'left_rel_'; export const RIGHT_HANDLE_ID_PREFIX = 'right_rel_'; @@ -78,16 +77,7 @@ const arePropsEqual = ( export const TableNodeField: React.FC = React.memo( ({ field, focused, tableNodeId, highlighted, visible, isConnectable }) => { - const { - removeField, - relationships, - readonly, - updateField, - highlightedCustomType, - } = useChartDB(); - const [editMode, setEditMode] = useState(false); - const [fieldName, setFieldName] = useState(field.name); - const inputRef = React.useRef(null); + const { relationships, readonly, highlightedCustomType } = useChartDB(); const updateNodeInternals = useUpdateNodeInternals(); const connection = useConnection(); @@ -152,23 +142,6 @@ export const TableNodeField: React.FC = React.memo( } }, [tableNodeId, updateNodeInternals, numberOfEdgesToField]); - const editFieldName = useCallback(() => { - if (!editMode) return; - if (fieldName.trim()) { - updateField(tableNodeId, field.id, { name: fieldName.trim() }); - } - setEditMode(false); - }, [fieldName, field.id, updateField, editMode, tableNodeId]); - - const abortEdit = useCallback(() => { - setEditMode(false); - setFieldName(field.name); - }, [field.name]); - - useClickAway(inputRef, editFieldName); - useKeyPressEvent('Enter', editFieldName); - useKeyPressEvent('Escape', abortEdit); - const { checkIfFieldRemoved, checkIfNewField, @@ -272,17 +245,32 @@ export const TableNodeField: React.FC = React.memo( fieldDiffChangedPrecision, } = diffState; - const enterEditMode = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - setEditMode(true); - }, []); - const isCustomTypeHighlighted = useMemo(() => { if (!highlightedCustomType) return false; return field.type.name === highlightedCustomType.name; }, [highlightedCustomType, field.type.name]); const { showFieldAttributes } = useLocalConfig(); + const { closeAllTablesInSidebar } = useLayout(); + const { setEditTableModeTable } = useCanvas(); + const openEditTableOnField = useCallback(() => { + if (readonly) { + return; + } + + closeAllTablesInSidebar(); + setEditTableModeTable({ + tableId: tableNodeId, + fieldId: field.id, + }); + }, [ + setEditTableModeTable, + closeAllTablesInSidebar, + tableNodeId, + field.id, + readonly, + ]); + return (
= React.memo( 'flex items-center gap-1 min-w-0 flex-1 text-left', { 'font-semibold': field.primaryKey || field.unique, - 'w-full': editMode, } )} > @@ -365,54 +352,31 @@ export const TableNodeField: React.FC = React.memo( ) : isDiffFieldChanged && !isSummaryOnly ? ( ) : null} - {editMode && !readonly ? ( - <> - e.stopPropagation()} - onChange={(e) => setFieldName(e.target.value)} - className="h-5 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900" - /> - - - ) : ( - - {fieldDiffChangedName ? ( - <> - {field.name}{' '} - {' '} - {fieldDiffChangedName} - - ) : ( - field.name - )} - - )} - {field.comments && !editMode ? ( + + + {fieldDiffChangedName ? ( + <> + {field.name}{' '} + {' '} + {fieldDiffChangedName} + + ) : ( + field.name + )} + + {field.comments ? (
@@ -423,37 +387,14 @@ export const TableNodeField: React.FC = React.memo( ) : null}
- {editMode ? null : ( -
- {(field.primaryKey && - fieldDiffChangedPrimaryKey === null) || - fieldDiffChangedPrimaryKey ? ( -
- -
- ) : null} +
+ {(field.primaryKey && + fieldDiffChangedPrimaryKey === null) || + fieldDiffChangedPrimaryKey ? (
= React.memo( ? 'text-green-800 dark:text-green-200' : '', isDiffFieldChanged && - !isDiffFieldRemoved && !isSummaryOnly && + !isDiffFieldRemoved && !isDiffNewField ? 'text-sky-800 dark:text-sky-200' : '' )} > - - {fieldDiffChangedType ? ( - <> - - {field.type.name.split(' ')[0]} - {' '} - { - fieldDiffChangedType.name.split( - ' ' - )[0] - } - - ) : ( - `${field.type.name.split(' ')[0]}${ - showFieldAttributes - ? generateDBFieldSuffix({ - ...field, - ...{ - precision: - fieldDiffChangedPrecision ?? - field.precision, - scale: - fieldDiffChangedScale ?? - field.scale, - characterMaximumLength: - fieldDiffChangedCharacterMaximumLength ?? - field.characterMaximumLength, - }, - }) - : '' - }` - )} - {fieldDiffChangedNullable !== null ? ( - fieldDiffChangedNullable ? ( - ? - ) : ( - ? - ) - ) : field.nullable ? ( - '?' - ) : ( - '' - )} - +
- {readonly ? null : ( -
- -
+ ) : null} + +
+ + {fieldDiffChangedType ? ( + <> + + {field.type.name.split(' ')[0]} + {' '} + {fieldDiffChangedType.name.split(' ')[0]} + + ) : ( + `${field.type.name.split(' ')[0]}${ + showFieldAttributes + ? generateDBFieldSuffix({ + ...field, + ...{ + precision: + fieldDiffChangedPrecision ?? + field.precision, + scale: + fieldDiffChangedScale ?? + field.scale, + characterMaximumLength: + fieldDiffChangedCharacterMaximumLength ?? + field.characterMaximumLength, + }, + }) + : '' + }` + )} + {fieldDiffChangedNullable !== null ? ( + fieldDiffChangedNullable ? ( + ? + ) : ( + ? + ) + ) : field.nullable ? ( + '?' + ) : ( + '' + )} +
- )} + {readonly ? null : ( +
+ +
+ )} +
); }, diff --git a/src/pages/editor-page/canvas/table-node/table-node.tsx b/src/pages/editor-page/canvas/table-node/table-node.tsx index fd54ad7bc..5f0f8ea64 100644 --- a/src/pages/editor-page/canvas/table-node/table-node.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node.tsx @@ -14,7 +14,6 @@ import { Table2, ChevronDown, ChevronUp, - Check, CircleDotDashed, SquareDot, SquarePlus, @@ -38,8 +37,6 @@ import { TableNodeContextMenu } from './table-node-context-menu'; import { cn } from '@/lib/utils'; import { TableNodeDependencyIndicator } from './table-node-dependency-indicator'; import type { EdgeType } from '../canvas'; -import { Input } from '@/components/input/input'; -import { useClickAway, useKeyPressEvent } from 'react-use'; import { Tooltip, TooltipContent, @@ -47,6 +44,8 @@ import { } from '@/components/tooltip/tooltip'; import { useDiff } from '@/context/diff-context/use-diff'; import { TableNodeStatus } from './table-node-status/table-node-status'; +import { TableEditMode } from './table-edit-mode/table-edit-mode'; +import { useCanvas } from '@/hooks/use-canvas'; export type TableNodeType = Node< { @@ -74,13 +73,29 @@ export const TableNode: React.FC> = React.memo( }) => { const { updateTable, relationships, readonly } = useChartDB(); const edges = useStore((store) => store.edges) as EdgeType[]; - const { openTableFromSidebar, selectSidebarSection } = useLayout(); + const { + openTableFromSidebar, + selectSidebarSection, + closeAllTablesInSidebar, + } = useLayout(); const [expanded, setExpanded] = useState(table.expanded ?? false); const { t } = useTranslation(); - const [editMode, setEditMode] = useState(false); - const [tableName, setTableName] = useState(table.name); - const inputRef = React.useRef(null); const [isHovering, setIsHovering] = useState(false); + const { setEditTableModeTable, editTableModeTable } = useCanvas(); + + // Get edit mode state directly from context + const editTableMode = useMemo( + () => editTableModeTable?.tableId === table.id, + [editTableModeTable, table.id] + ); + const editTableModeFieldId = useMemo( + () => (editTableMode ? editTableModeTable?.fieldId : null), + [editTableMode, editTableModeTable] + ); + + // Store the initial field count when entering edit mode to keep table height fixed + const [editModeInitialFieldCount, setEditModeInitialFieldCount] = + useState(null); const connection = useConnection(); @@ -101,6 +116,17 @@ export const TableNode: React.FC> = React.memo( const fields = useMemo(() => table.fields, [table.fields]); + // Effect to manage field count when entering/exiting edit mode + useEffect(() => { + if (editTableMode && editModeInitialFieldCount === null) { + // Entering edit mode - capture current field count + setEditModeInitialFieldCount(fields.length); + } else if (!editTableMode && editModeInitialFieldCount !== null) { + // Exiting edit mode - reset + setEditModeInitialFieldCount(null); + } + }, [editTableMode, fields.length, editModeInitialFieldCount]); + const tableChangedName = useMemo( () => getTableNewName({ tableId: table.id }), [getTableNewName, table.id] @@ -235,14 +261,20 @@ export const TableNode: React.FC> = React.memo( }, [relationships]); const visibleFields = useMemo(() => { - if (expanded || fields.length <= TABLE_MINIMIZED_FIELDS) { - return fields; + // If in edit mode, use the initial field count to keep consistent height + const fieldsToConsider = + editTableMode && editModeInitialFieldCount !== null + ? fields.slice(0, editModeInitialFieldCount) + : fields; + + if (expanded || fieldsToConsider.length <= TABLE_MINIMIZED_FIELDS) { + return fieldsToConsider; } const mustDisplayedFields: DBField[] = []; const nonMustDisplayedFields: DBField[] = []; - for (const field of fields) { + for (const field of fieldsToConsider) { if (relatedFieldIds.has(field.id) || field.primaryKey) { mustDisplayedFields.push(field); } else { @@ -269,40 +301,18 @@ export const TableNode: React.FC> = React.memo( ...visibleMustDisplayedFields, ...visibleNonMustDisplayedFields, ]); - const result = fields.filter((field) => + const result = fieldsToConsider.filter((field) => visibleFieldsSet.has(field) ); return result; - }, [expanded, fields, relatedFieldIds]); - - const editTableName = useCallback(() => { - if (!editMode) return; - if (tableName.trim()) { - updateTable(table.id, { name: tableName.trim() }); - } - setEditMode(false); - }, [tableName, table.id, updateTable, editMode]); - - const abortEdit = useCallback(() => { - setEditMode(false); - setTableName(table.name); - }, [table.name]); - - useClickAway(inputRef, editTableName); - useKeyPressEvent('Enter', editTableName); - useKeyPressEvent('Escape', abortEdit); - - const enterEditMode = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - setEditMode(true); - }, []); - - React.useEffect(() => { - if (table.name.trim()) { - setTableName(table.name.trim()); - } - }, [table.name]); + }, [ + expanded, + fields, + relatedFieldIds, + editTableMode, + editModeInitialFieldCount, + ]); const tableClassName = useMemo( () => @@ -337,7 +347,9 @@ export const TableNode: React.FC> = React.memo( : '', isDiffTableRemoved ? 'outline outline-[3px] outline-red-500 dark:outline-red-900 outline-offset-[5px]' - : '' + : editTableMode + ? 'invisible' + : '' ), [ selected, @@ -350,16 +362,45 @@ export const TableNode: React.FC> = React.memo( isDiffNewTable, isDiffTableRemoved, isTarget, + editTableMode, ] ); + const enterEditTableMode = useCallback(() => { + if (readonly) { + return; + } + + closeAllTablesInSidebar(); + setEditTableModeTable({ tableId: table.id }); + }, [ + table.id, + setEditTableModeTable, + closeAllTablesInSidebar, + readonly, + ]); + + const exitEditTableMode = useCallback(() => { + setEditTableModeTable(null); + }, [setEditTableModeTable]); + return ( + {editTableMode ? ( + { + exitEditTableMode(); + }} + /> + ) : null}
{ - if (e.detail === 2) { - openTableInEditor(); + if (e.detail === 2 && !readonly) { + enterEditTableMode(); } }} onMouseEnter={() => setIsHovering(true)} @@ -456,47 +497,14 @@ export const TableNode: React.FC> = React.memo( - ) : editMode && !readonly ? ( - <> - e.stopPropagation()} - onChange={(e) => - setTableName(e.target.value) - } - className="h-6 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900" - /> - - ) : ( - - - - - - {t('tool_tips.double_click_to_edit')} - - + )}
- {readonly || editMode ? null : ( + {readonly ? null : ( )} - {editMode ? null : ( - - )} +
@@ -544,7 +550,9 @@ export const TableNode: React.FC> = React.memo( /> ))}
- {fields.length > TABLE_MINIMIZED_FIELDS && ( + {(editTableMode && editModeInitialFieldCount !== null + ? editModeInitialFieldCount + : fields.length) > TABLE_MINIMIZED_FIELDS && (
void; } -const generateFieldRegexPatterns = ( - dataType: DataTypeData -): { - regex?: string; - extractRegex?: RegExp; -} => { - if (!dataType.fieldAttributes) { - return { regex: undefined, extractRegex: undefined }; - } - - const typeName = dataType.name; - const fieldAttributes = dataType.fieldAttributes; - - if (fieldAttributes.hasCharMaxLength) { - if (fieldAttributes.hasCharMaxLengthOption) { - return { - regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)$`, - extractRegex: /\((\d+|max)\)/i, - }; - } - return { - regex: `^${typeName}\\(\\d+\\)$`, - extractRegex: /\((\d+)\)/, - }; - } - - if (fieldAttributes.precision && fieldAttributes.scale) { - return { - regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`, - extractRegex: new RegExp( - `${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)` - ), - }; - } - - if (fieldAttributes.precision) { - return { - regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`, - extractRegex: /\((\d+)\)/, - }; - } - - return { regex: undefined, extractRegex: undefined }; -}; - export const TableField: React.FC = ({ table, field, updateField, removeField, }) => { - const { databaseType, customTypes, readonly } = useChartDB(); + const { databaseType, readonly } = useChartDB(); const { t } = useTranslation(); - // Only calculate primary key fields, not just count - const primaryKeyFields = useMemo(() => { - return table.fields.filter((f) => f.primaryKey); - }, [table.fields]); - - const primaryKeyCount = primaryKeyFields.length; - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: field.id }); - const dataFieldOptions = useMemo(() => { - const standardTypes: SelectBoxOption[] = sortedDataTypeMap[ - databaseType - ].map((type) => { - const regexPatterns = generateFieldRegexPatterns(type); - - return { - label: type.name, - value: type.id, - regex: regexPatterns.regex, - extractRegex: regexPatterns.extractRegex, - group: customTypes?.length ? 'Standard Types' : undefined, - }; - }); - - if (!customTypes?.length) { - return standardTypes; - } - - // Add custom types as options - const customTypeOptions: SelectBoxOption[] = customTypes.map( - (type) => ({ - label: type.name, - value: type.name, - description: - type.kind === 'enum' ? `${type.values?.join(' | ')}` : '', - group: 'Custom Types', - }) - ); - - return [...standardTypes, ...customTypeOptions]; - }, [databaseType, customTypes]); - - const onChangeDataType = useCallback< - NonNullable - >( - (value, regexMatches) => { - const dataType = sortedDataTypeMap[databaseType].find( - (v) => v.id === value - ) ?? { - id: value as string, - name: value as string, - }; - - let characterMaximumLength: string | undefined = undefined; - let precision: number | undefined = undefined; - let scale: number | undefined = undefined; - - if (regexMatches?.length) { - if (dataType?.fieldAttributes?.hasCharMaxLength) { - characterMaximumLength = regexMatches[1]?.toLowerCase(); - } else if ( - dataType?.fieldAttributes?.precision && - dataType?.fieldAttributes?.scale - ) { - precision = parseInt(regexMatches[1]); - scale = regexMatches[2] - ? parseInt(regexMatches[2]) - : undefined; - } else if (dataType?.fieldAttributes?.precision) { - precision = parseInt(regexMatches[1]); - } - } else { - if ( - dataType?.fieldAttributes?.hasCharMaxLength && - field.characterMaximumLength - ) { - characterMaximumLength = field.characterMaximumLength; - } - - if (dataType?.fieldAttributes?.precision && field.precision) { - precision = field.precision; - } - - if (dataType?.fieldAttributes?.scale && field.scale) { - scale = field.scale; - } - } - - updateField({ - characterMaximumLength, - precision, - scale, - increment: undefined, - default: undefined, - type: dataTypeDataToDataType( - dataType ?? { - id: value as string, - name: value as string, - } - ), - }); - }, - [ - updateField, - databaseType, - field.characterMaximumLength, - field.precision, - field.scale, - ] - ); + const { + dataFieldOptions, + handleDataTypeChange, + handlePrimaryKeyToggle, + handleNullableToggle, + handleNameChange, + generateFieldSuffix, + fieldName, + nullable, + primaryKey, + } = useUpdateTableField(table, field, updateField); const style = { transform: CSS.Translate.toString(transform), transition, }; - const handlePrimaryKeyToggle = useCallback( - (value: boolean) => { - if (value) { - // When setting as primary key - const updates: Partial = { - primaryKey: true, - }; - // Only auto-set unique if this will be the only primary key - if (primaryKeyCount === 0) { - updates.unique = true; - } - updateField(updates); - } else { - // When removing primary key - updateField({ - primaryKey: false, - }); - } - }, - [primaryKeyCount, updateField] - ); - - const handleNullableToggle = useCallback( - (value: boolean) => { - updateField({ nullable: value }); - }, - [updateField] - ); - - const handleNameChange = useCallback( - (e: React.ChangeEvent) => { - updateField({ name: e.target.value }); - }, - [updateField] - ); - return (
= ({ placeholder={t( 'side_panel.tables_section.table.field_name' )} - value={field.name} - onChange={handleNameChange} + value={fieldName} + onChange={(e) => + handleNameChange(e.target.value) + } readOnly={readonly} /> @@ -285,13 +101,9 @@ export const TableField: React.FC = ({ value={field.type.id} valueSuffix={generateDBFieldSuffix(field)} optionSuffix={(option) => - generateDBFieldSuffix(field, { - databaseType, - forceExtended: true, - typeId: option.value, - }) + generateFieldSuffix(option.value) } - onChange={onChangeDataType} + onChange={handleDataTypeChange} emptyPlaceholder={t( 'side_panel.tables_section.table.no_types_found' )} @@ -312,7 +124,7 @@ export const TableField: React.FC = ({ @@ -328,7 +140,7 @@ export const TableField: React.FC = ({ From af3638da7a9b70f281ceaddbc2f712a713d90cda Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Mon, 15 Sep 2025 19:01:50 +0300 Subject: [PATCH 005/156] feat(import-db): add DBML syntax to import database dialog (#768) * feat(editor): add import DBML syntax in import database dialog * fix * fix * fix * fix * fix --------- Co-authored-by: Guy Ben-Aharon --- src/components/code-snippet/code-snippet.tsx | 2 +- .../code-snippet/languages/dbml-language.ts | 54 +++++- .../import-database/import-database.tsx | 118 +++++++------ .../instructions-section.tsx | 31 +++- .../instructions/dbml-instructions.tsx | 47 ++++++ .../import-database/sql-validation-status.tsx | 4 +- .../create-diagram-dialog.tsx | 17 +- .../import-database-dialog.tsx | 8 +- .../import-dbml-dialog/import-dbml-dialog.tsx | 36 ++-- src/lib/data/sql-export/export-sql-script.ts | 27 ++- .../__tests__/test-minimal-type.test.ts | 66 -------- .../__tests__/dbml-integration.test.ts | 149 +++++++++++++++++ src/lib/dbml/dbml-import/dbml-import.ts | 10 +- src/lib/dbml/dbml-import/verify-dbml.ts | 52 ++++++ .../__tests__/detect-import-type.test.ts | 157 ++++++++++++++++++ src/lib/import-method/detect-import-method.ts | 59 +++++++ src/lib/import-method/import-method.ts | 1 + 17 files changed, 668 insertions(+), 170 deletions(-) create mode 100644 src/dialogs/common/import-database/instructions-section/instructions/dbml-instructions.tsx delete mode 100644 src/lib/data/sql-import/dialect-importers/postgresql/__tests__/test-minimal-type.test.ts create mode 100644 src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts create mode 100644 src/lib/dbml/dbml-import/verify-dbml.ts create mode 100644 src/lib/import-method/__tests__/detect-import-type.test.ts create mode 100644 src/lib/import-method/detect-import-method.ts create mode 100644 src/lib/import-method/import-method.ts diff --git a/src/components/code-snippet/code-snippet.tsx b/src/components/code-snippet/code-snippet.tsx index e3389ca7c..3d5b997f3 100644 --- a/src/components/code-snippet/code-snippet.tsx +++ b/src/components/code-snippet/code-snippet.tsx @@ -38,7 +38,7 @@ export interface CodeSnippetProps { className?: string; code: string; codeToCopy?: string; - language?: 'sql' | 'shell'; + language?: 'sql' | 'shell' | 'dbml'; loading?: boolean; autoScroll?: boolean; isComplete?: boolean; diff --git a/src/components/code-snippet/languages/dbml-language.ts b/src/components/code-snippet/languages/dbml-language.ts index 71a77a7b8..31034a638 100644 --- a/src/components/code-snippet/languages/dbml-language.ts +++ b/src/components/code-snippet/languages/dbml-language.ts @@ -9,12 +9,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => { base: 'vs-dark', inherit: true, rules: [ + { token: 'comment', foreground: '6A9955' }, // Comments { token: 'keyword', foreground: '569CD6' }, // Table, Ref keywords { token: 'string', foreground: 'CE9178' }, // Strings { token: 'annotation', foreground: '9CDCFE' }, // [annotations] { token: 'delimiter', foreground: 'D4D4D4' }, // Braces {} { token: 'operator', foreground: 'D4D4D4' }, // Operators - { token: 'datatype', foreground: '4EC9B0' }, // Data types + { token: 'type', foreground: '4EC9B0' }, // Data types + { token: 'identifier', foreground: '9CDCFE' }, // Field names ], colors: {}, }); @@ -23,12 +25,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => { base: 'vs', inherit: true, rules: [ + { token: 'comment', foreground: '008000' }, // Comments { token: 'keyword', foreground: '0000FF' }, // Table, Ref keywords { token: 'string', foreground: 'A31515' }, // Strings { token: 'annotation', foreground: '001080' }, // [annotations] { token: 'delimiter', foreground: '000000' }, // Braces {} { token: 'operator', foreground: '000000' }, // Operators { token: 'type', foreground: '267F99' }, // Data types + { token: 'identifier', foreground: '001080' }, // Field names ], colors: {}, }); @@ -37,23 +41,59 @@ export const setupDBMLLanguage = (monaco: Monaco) => { const datatypePattern = dataTypesNames.join('|'); monaco.languages.setMonarchTokensProvider('dbml', { - keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum'], + keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum', 'enum'], datatypes: dataTypesNames, + operators: ['>', '<', '-'], + tokenizer: { root: [ + // Comments + [/\/\/.*$/, 'comment'], + + // Keywords - case insensitive [ /\b([Tt][Aa][Bb][Ll][Ee]|[Ee][Nn][Uu][Mm]|[Rr][Ee][Ff]|[Ii][Nn][Dd][Ee][Xx][Ee][Ss]|[Nn][Oo][Tt][Ee])\b/, 'keyword', ], + + // Annotations in brackets [/\[.*?\]/, 'annotation'], + + // Strings [/'''/, 'string', '@tripleQuoteString'], - [/".*?"/, 'string'], - [/'.*?'/, 'string'], + [/"([^"\\]|\\.)*$/, 'string.invalid'], // non-terminated string + [/'([^'\\]|\\.)*$/, 'string.invalid'], // non-terminated string + [/"/, 'string', '@string_double'], + [/'/, 'string', '@string_single'], [/`.*?`/, 'string'], - [/[{}]/, 'delimiter'], - [/[<>]/, 'operator'], - [new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'], // Added 'i' flag for case-insensitive matching + + // Delimiters and operators + [/[{}()]/, 'delimiter'], + [/[<>-]/, 'operator'], + [/:/, 'delimiter'], + + // Data types + [new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'], + + // Numbers + [/\d+/, 'number'], + + // Identifiers + [/[a-zA-Z_]\w*/, 'identifier'], + ], + + string_double: [ + [/[^\\"]+/, 'string'], + [/\\./, 'string.escape'], + [/"/, 'string', '@pop'], ], + + string_single: [ + [/[^\\']+/, 'string'], + [/\\./, 'string.escape'], + [/'/, 'string', '@pop'], + ], + tripleQuoteString: [ [/[^']+/, 'string'], [/'''/, 'string', '@pop'], diff --git a/src/dialogs/common/import-database/import-database.tsx b/src/dialogs/common/import-database/import-database.tsx index e818da5f5..3382350a0 100644 --- a/src/dialogs/common/import-database/import-database.tsx +++ b/src/dialogs/common/import-database/import-database.tsx @@ -42,6 +42,10 @@ import { type ValidationResult, } from '@/lib/data/sql-import/sql-validator'; import { SQLValidationStatus } from './sql-validation-status'; +import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language'; +import type { ImportMethod } from '@/lib/import-method/import-method'; +import { detectImportMethod } from '@/lib/import-method/detect-import-method'; +import { verifyDBML } from '@/lib/dbml/dbml-import/verify-dbml'; const calculateContentSizeMB = (content: string): number => { return content.length / (1024 * 1024); // Convert to MB @@ -55,49 +59,6 @@ const calculateIsLargeFile = (content: string): boolean => { const errorScriptOutputMessage = 'Invalid JSON. Please correct it or contact us at support@chartdb.io for help.'; -// Helper to detect if content is likely SQL DDL or JSON -const detectContentType = (content: string): 'query' | 'ddl' | null => { - if (!content || content.trim().length === 0) return null; - - // Common SQL DDL keywords - const ddlKeywords = [ - 'CREATE TABLE', - 'ALTER TABLE', - 'DROP TABLE', - 'CREATE INDEX', - 'CREATE VIEW', - 'CREATE PROCEDURE', - 'CREATE FUNCTION', - 'CREATE SCHEMA', - 'CREATE DATABASE', - ]; - - const upperContent = content.toUpperCase(); - - // Check for SQL DDL patterns - const hasDDLKeywords = ddlKeywords.some((keyword) => - upperContent.includes(keyword) - ); - if (hasDDLKeywords) return 'ddl'; - - // Check if it looks like JSON - try { - // Just check structure, don't need full parse for detection - if ( - (content.trim().startsWith('{') && content.trim().endsWith('}')) || - (content.trim().startsWith('[') && content.trim().endsWith(']')) - ) { - return 'query'; - } - } catch (error) { - // Not valid JSON, might be partial - console.error('Error detecting content type:', error); - } - - // If we can't confidently detect, return null - return null; -}; - export interface ImportDatabaseProps { goBack?: () => void; onImport: () => void; @@ -111,8 +72,8 @@ export interface ImportDatabaseProps { >; keepDialogAfterImport?: boolean; title: string; - importMethod: 'query' | 'ddl'; - setImportMethod: (method: 'query' | 'ddl') => void; + importMethod: ImportMethod; + setImportMethod: (method: ImportMethod) => void; } export const ImportDatabase: React.FC = ({ @@ -152,9 +113,9 @@ export const ImportDatabase: React.FC = ({ setShowCheckJsonButton(false); }, [importMethod, setScriptResult]); - // Check if the ddl is valid + // Check if the ddl or dbml is valid useEffect(() => { - if (importMethod !== 'ddl') { + if (importMethod === 'query') { setSqlValidation(null); setShowAutoFixButton(false); return; @@ -163,9 +124,48 @@ export const ImportDatabase: React.FC = ({ if (!scriptResult.trim()) { setSqlValidation(null); setShowAutoFixButton(false); + setErrorMessage(''); return; } + if (importMethod === 'dbml') { + // Validate DBML by parsing it + const validateResponse = verifyDBML(scriptResult); + if (!validateResponse.hasError) { + setErrorMessage(''); + setSqlValidation({ + isValid: true, + errors: [], + warnings: [], + }); + } else { + let errorMsg = 'Invalid DBML syntax'; + let line: number = 1; + + if (validateResponse.parsedError) { + errorMsg = validateResponse.parsedError.message; + line = validateResponse.parsedError.line; + } + + setSqlValidation({ + isValid: false, + errors: [ + { + message: errorMsg, + line: line, + type: 'syntax' as const, + }, + ], + warnings: [], + }); + setErrorMessage(errorMsg); + } + + setShowAutoFixButton(false); + return; + } + + // SQL validation // First run our validation based on database type const validation = validateSQL(scriptResult, databaseType); setSqlValidation(validation); @@ -338,7 +338,7 @@ export const ImportDatabase: React.FC = ({ const isLargeFile = calculateIsLargeFile(content); // First, detect content type to determine if we should switch modes - const detectedType = detectContentType(content); + const detectedType = detectImportMethod(content); if (detectedType && detectedType !== importMethod) { // Switch to the detected mode immediately setImportMethod(detectedType); @@ -352,7 +352,7 @@ export const ImportDatabase: React.FC = ({ ?.run(); }, 100); } - // For DDL mode, do NOT format as it can break the SQL + // For DDL and DBML modes, do NOT format as it can break the syntax } else { // Content type didn't change, apply formatting based on current mode if (importMethod === 'query' && !isLargeFile) { @@ -363,7 +363,7 @@ export const ImportDatabase: React.FC = ({ ?.run(); }, 100); } - // For DDL mode or large files, do NOT format + // For DDL and DBML modes or large files, do NOT format } }); @@ -410,16 +410,25 @@ export const ImportDatabase: React.FC = ({
{importMethod === 'query' ? 'Smart Query Output' - : 'SQL Script'} + : importMethod === 'dbml' + ? 'DBML Script' + : 'SQL Script'}
}> } onMount={handleEditorDidMount} + beforeMount={setupDBMLLanguage} theme={ effectiveTheme === 'dark' ? 'dbml-dark' @@ -430,7 +439,6 @@ export const ImportDatabase: React.FC = ({ minimap: { enabled: false }, scrollBeyondLastLine: false, automaticLayout: true, - glyphMargin: false, lineNumbers: 'on', guides: { indentation: false, @@ -455,7 +463,9 @@ export const ImportDatabase: React.FC = ({
- {errorMessage || (importMethod === 'ddl' && sqlValidation) ? ( + {errorMessage || + ((importMethod === 'ddl' || importMethod === 'dbml') && + sqlValidation) ? ( >; - importMethod: 'query' | 'ddl'; - setImportMethod: (method: 'query' | 'ddl') => void; + importMethod: ImportMethod; + setImportMethod: (method: ImportMethod) => void; showSSMSInfoDialog: boolean; setShowSSMSInfoDialog: (show: boolean) => void; } @@ -125,9 +127,9 @@ export const InstructionsSection: React.FC = ({ className="ml-1 flex-wrap justify-start gap-2" value={importMethod} onValueChange={(value) => { - let selectedImportMethod: 'query' | 'ddl' = 'query'; + let selectedImportMethod: ImportMethod = 'query'; if (value) { - selectedImportMethod = value as 'query' | 'ddl'; + selectedImportMethod = value as ImportMethod; } setImportMethod(selectedImportMethod); @@ -150,10 +152,20 @@ export const InstructionsSection: React.FC = ({ className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700" > - + SQL Script + + + + + DBML +
)} @@ -167,11 +179,16 @@ export const InstructionsSection: React.FC = ({ showSSMSInfoDialog={showSSMSInfoDialog} setShowSSMSInfoDialog={setShowSSMSInfoDialog} /> - ) : ( + ) : importMethod === 'ddl' ? ( + ) : ( + )}
diff --git a/src/dialogs/common/import-database/instructions-section/instructions/dbml-instructions.tsx b/src/dialogs/common/import-database/instructions-section/instructions/dbml-instructions.tsx new file mode 100644 index 000000000..41f168ead --- /dev/null +++ b/src/dialogs/common/import-database/instructions-section/instructions/dbml-instructions.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import type { DatabaseType } from '@/lib/domain/database-type'; +import type { DatabaseEdition } from '@/lib/domain/database-edition'; +import { CodeSnippet } from '@/components/code-snippet/code-snippet'; +import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language'; + +export interface DBMLInstructionsProps { + databaseType: DatabaseType; + databaseEdition?: DatabaseEdition; +} + +export const DBMLInstructions: React.FC = () => { + return ( + <> +
+
+ Paste your DBML (Database Markup Language) schema definition + here → +
+
+ +
+

Example:

+ users.id] + title varchar + content text +}`} + language={'dbml'} + /> +
+ + ); +}; diff --git a/src/dialogs/common/import-database/sql-validation-status.tsx b/src/dialogs/common/import-database/sql-validation-status.tsx index ed8d64f5c..d78b03630 100644 --- a/src/dialogs/common/import-database/sql-validation-status.tsx +++ b/src/dialogs/common/import-database/sql-validation-status.tsx @@ -73,7 +73,7 @@ export const SQLValidationStatus: React.FC = ({ {hasErrors ? (
- +
{validation?.errors .slice(0, 3) @@ -137,7 +137,7 @@ export const SQLValidationStatus: React.FC = ({ {hasWarnings && !hasErrors ? (
- +
diff --git a/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx b/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx index b08a8390b..ffc92875d 100644 --- a/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx +++ b/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx @@ -22,6 +22,11 @@ import { sqlImportToDiagram } from '@/lib/data/sql-import'; import type { SelectedTable } from '@/lib/data/import-metadata/filter-metadata'; import { filterMetadataByTables } from '@/lib/data/import-metadata/filter-metadata'; import { MAX_TABLES_WITHOUT_SHOWING_FILTER } from '../common/select-tables/constants'; +import { + defaultDBMLDiagramName, + importDBMLToDiagram, +} from '@/lib/dbml/dbml-import/dbml-import'; +import type { ImportMethod } from '@/lib/import-method/import-method'; export interface CreateDiagramDialogProps extends BaseDialogProps {} @@ -30,7 +35,7 @@ export const CreateDiagramDialog: React.FC = ({ }) => { const { diagramId } = useChartDB(); const { t } = useTranslation(); - const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query'); + const [importMethod, setImportMethod] = useState('query'); const [databaseType, setDatabaseType] = useState( DatabaseType.GENERIC ); @@ -89,6 +94,14 @@ export const CreateDiagramDialog: React.FC = ({ sourceDatabaseType: databaseType, targetDatabaseType: databaseType, }); + } else if (importMethod === 'dbml') { + diagram = await importDBMLToDiagram(scriptResult, { + databaseType, + }); + // Update the diagram name if it's the default + if (diagram.name === defaultDBMLDiagramName) { + diagram.name = `Diagram ${diagramNumber}`; + } } else { let metadata: DatabaseMetadata | undefined = databaseMetadata; @@ -171,7 +184,7 @@ export const CreateDiagramDialog: React.FC = ({ try { setIsParsingMetadata(true); - if (importMethod === 'ddl') { + if (importMethod === 'ddl' || importMethod === 'dbml') { await importNewDiagram(); } else { // Parse metadata asynchronously to avoid blocking the UI diff --git a/src/dialogs/import-database-dialog/import-database-dialog.tsx b/src/dialogs/import-database-dialog/import-database-dialog.tsx index e7dd28af1..d9ecff179 100644 --- a/src/dialogs/import-database-dialog/import-database-dialog.tsx +++ b/src/dialogs/import-database-dialog/import-database-dialog.tsx @@ -15,6 +15,8 @@ import { useReactFlow } from '@xyflow/react'; import type { BaseDialogProps } from '../common/base-dialog-props'; import { useAlert } from '@/context/alert-context/alert-context'; import { sqlImportToDiagram } from '@/lib/data/sql-import'; +import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import'; +import type { ImportMethod } from '@/lib/import-method/import-method'; export interface ImportDatabaseDialogProps extends BaseDialogProps { databaseType: DatabaseType; @@ -24,7 +26,7 @@ export const ImportDatabaseDialog: React.FC = ({ dialog, databaseType, }) => { - const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query'); + const [importMethod, setImportMethod] = useState('query'); const { closeImportDatabaseDialog } = useDialog(); const { showAlert } = useAlert(); const { @@ -65,6 +67,10 @@ export const ImportDatabaseDialog: React.FC = ({ sourceDatabaseType: databaseType, targetDatabaseType: databaseType, }); + } else if (importMethod === 'dbml') { + diagram = await importDBMLToDiagram(scriptResult, { + databaseType, + }); } else { const databaseMetadata: DatabaseMetadata = loadDatabaseMetadata(scriptResult); diff --git a/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx b/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx index b03b4ad48..1bfd2be84 100644 --- a/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx +++ b/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx @@ -23,24 +23,19 @@ import { useTranslation } from 'react-i18next'; import { Editor } from '@/components/code-snippet/code-snippet'; import { useTheme } from '@/hooks/use-theme'; import { AlertCircle } from 'lucide-react'; -import { - importDBMLToDiagram, - sanitizeDBML, - preprocessDBML, -} from '@/lib/dbml/dbml-import/dbml-import'; +import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import'; import { useChartDB } from '@/hooks/use-chartdb'; -import { Parser } from '@dbml/core'; import { useCanvas } from '@/hooks/use-canvas'; import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language'; import type { DBTable } from '@/lib/domain/db-table'; import { useToast } from '@/components/toast/use-toast'; import { Spinner } from '@/components/spinner/spinner'; import { debounce } from '@/lib/utils'; -import { parseDBMLError } from '@/lib/dbml/dbml-import/dbml-import-error'; import { clearErrorHighlight, highlightErrorLine, } from '@/components/code-snippet/dbml/utils'; +import { verifyDBML } from '@/lib/dbml/dbml-import/verify-dbml'; export interface ImportDBMLDialogProps extends BaseDialogProps { withCreateEmptyDiagram?: boolean; @@ -93,6 +88,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`; relationships, removeTables, removeRelationships, + databaseType, } = useChartDB(); const { reorderTables } = useCanvas(); const [reorder, setReorder] = useState(false); @@ -126,16 +122,15 @@ Ref: comments.user_id > users.id // Each comment is written by one user`; setErrorMessage(undefined); clearDecorations(); - if (!content.trim()) return; + if (!content.trim()) { + return; + } + + const validateResponse = verifyDBML(content); - try { - const preprocessedContent = preprocessDBML(content); - const sanitizedContent = sanitizeDBML(preprocessedContent); - const parser = new Parser(); - parser.parse(sanitizedContent, 'dbmlv2'); - } catch (e) { - const parsedError = parseDBMLError(e); - if (parsedError) { + if (validateResponse.hasError) { + if (validateResponse.parsedError) { + const parsedError = validateResponse.parsedError; setErrorMessage( t('import_dbml_dialog.error.description') + ` (1 error found - in line ${parsedError.line})` @@ -147,9 +142,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`; decorationsCollection.current, }); } else { - setErrorMessage( - e instanceof Error ? e.message : JSON.stringify(e) - ); + setErrorMessage(validateResponse.errorText); } } }, @@ -188,7 +181,9 @@ Ref: comments.user_id > users.id // Each comment is written by one user`; if (!dbmlContent.trim() || errorMessage) return; try { - const importedDiagram = await importDBMLToDiagram(dbmlContent); + const importedDiagram = await importDBMLToDiagram(dbmlContent, { + databaseType, + }); const tableIdsToRemove = tables .filter((table) => importedDiagram.tables?.some( @@ -267,6 +262,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`; toast, setReorder, t, + databaseType, ]); return ( diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index d69f3b4bb..4f149ef9f 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -5,7 +5,7 @@ import { databaseTypesWithCommentSupport, } from '@/lib/domain/database-type'; import type { DBTable } from '@/lib/domain/db-table'; -import type { DataType } from '../data-types/data-types'; +import { dataTypeMap, type DataType } from '../data-types/data-types'; import { generateCacheKey, getFromCache, setInCache } from './export-sql-cache'; import { exportMSSQL } from './export-per-type/mssql'; import { exportPostgreSQL } from './export-per-type/postgresql'; @@ -314,11 +314,26 @@ export const exportBaseSQL = ({ sqlScript += `(1)`; } - // Add precision and scale for numeric types - if (field.precision && field.scale) { - sqlScript += `(${field.precision}, ${field.scale})`; - } else if (field.precision) { - sqlScript += `(${field.precision})`; + // Add precision and scale for numeric types only + const precisionAndScaleTypes = dataTypeMap[targetDatabaseType] + .filter( + (t) => + t.fieldAttributes?.precision && t.fieldAttributes?.scale + ) + .map((t) => t.name); + + const isNumericType = precisionAndScaleTypes.some( + (t) => + field.type.name.toLowerCase().includes(t) || + typeName.toLowerCase().includes(t) + ); + + if (isNumericType) { + if (field.precision && field.scale) { + sqlScript += `(${field.precision}, ${field.scale})`; + } else if (field.precision) { + sqlScript += `(${field.precision})`; + } } // Handle NOT NULL constraint diff --git a/src/lib/data/sql-import/dialect-importers/postgresql/__tests__/test-minimal-type.test.ts b/src/lib/data/sql-import/dialect-importers/postgresql/__tests__/test-minimal-type.test.ts deleted file mode 100644 index 6ac32ca23..000000000 --- a/src/lib/data/sql-import/dialect-importers/postgresql/__tests__/test-minimal-type.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it } from 'vitest'; - -describe('node-sql-parser - CREATE TYPE handling', () => { - it('should show exact parser error for CREATE TYPE', async () => { - const { Parser } = await import('node-sql-parser'); - const parser = new Parser(); - const parserOpts = { - database: 'PostgreSQL', - }; - - console.log('\n=== Testing CREATE TYPE statement ==='); - const createTypeSQL = `CREATE TYPE spell_element AS ENUM ('fire', 'water', 'earth', 'air');`; - - try { - parser.astify(createTypeSQL, parserOpts); - console.log('CREATE TYPE parsed successfully'); - } catch (error) { - console.log('CREATE TYPE parse error:', (error as Error).message); - } - - console.log('\n=== Testing CREATE EXTENSION statement ==='); - const createExtensionSQL = `CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`; - - try { - parser.astify(createExtensionSQL, parserOpts); - console.log('CREATE EXTENSION parsed successfully'); - } catch (error) { - console.log( - 'CREATE EXTENSION parse error:', - (error as Error).message - ); - } - - console.log('\n=== Testing CREATE TABLE with custom type ==='); - const createTableWithTypeSQL = `CREATE TABLE wizards ( - id UUID PRIMARY KEY, - element spell_element DEFAULT 'fire' - );`; - - try { - parser.astify(createTableWithTypeSQL, parserOpts); - console.log('CREATE TABLE with custom type parsed successfully'); - } catch (error) { - console.log( - 'CREATE TABLE with custom type parse error:', - (error as Error).message - ); - } - - console.log('\n=== Testing CREATE TABLE with standard types only ==='); - const createTableStandardSQL = `CREATE TABLE wizards ( - id UUID PRIMARY KEY, - element VARCHAR(20) DEFAULT 'fire' - );`; - - try { - parser.astify(createTableStandardSQL, parserOpts); - console.log('CREATE TABLE with standard types parsed successfully'); - } catch (error) { - console.log( - 'CREATE TABLE with standard types parse error:', - (error as Error).message - ); - } - }); -}); diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts new file mode 100644 index 000000000..fdda08e9f --- /dev/null +++ b/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import { DatabaseType } from '@/lib/domain/database-type'; +import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import'; + +// This test verifies the DBML integration without UI components +describe('DBML Integration Tests', () => { + it('should handle DBML import in create diagram flow', async () => { + const dbmlContent = ` +Table users { + id uuid [pk, not null] + email varchar [unique, not null] + created_at timestamp +} + +Table posts { + id uuid [pk] + title varchar + content text + user_id uuid [ref: > users.id] + created_at timestamp +} + +Table comments { + id uuid [pk] + content text + post_id uuid [ref: > posts.id] + user_id uuid [ref: > users.id] +} + +// This will be ignored +TableGroup "Content" { + posts + comments +} + +// This will be ignored too +Note test_note { + 'This is a test note' +}`; + + const diagram = await importDBMLToDiagram(dbmlContent); + + // Verify basic structure + expect(diagram).toBeDefined(); + expect(diagram.tables).toHaveLength(3); + expect(diagram.relationships).toHaveLength(3); + + // Verify tables + const tableNames = diagram.tables?.map((t) => t.name).sort(); + expect(tableNames).toEqual(['comments', 'posts', 'users']); + + // Verify users table + const usersTable = diagram.tables?.find((t) => t.name === 'users'); + expect(usersTable).toBeDefined(); + expect(usersTable?.fields).toHaveLength(3); + + const emailField = usersTable?.fields.find((f) => f.name === 'email'); + expect(emailField?.unique).toBe(true); + expect(emailField?.nullable).toBe(false); + + // Verify relationships + // There should be 3 relationships total + expect(diagram.relationships).toHaveLength(3); + + // Find the relationship from users to posts (DBML ref is: posts.user_id > users.id) + // This creates a relationship FROM users TO posts (one user has many posts) + const postsTable = diagram.tables?.find((t) => t.name === 'posts'); + const usersTableId = usersTable?.id; + + const userPostRelation = diagram.relationships?.find( + (r) => + r.sourceTableId === usersTableId && + r.targetTableId === postsTable?.id + ); + + expect(userPostRelation).toBeDefined(); + expect(userPostRelation?.sourceCardinality).toBe('one'); + expect(userPostRelation?.targetCardinality).toBe('many'); + }); + + it('should handle DBML with special features', async () => { + const dbmlContent = ` +// Enum will be converted to varchar +Table users { + id int [pk] + status enum + tags text[] // Array will be converted to text + favorite_product_id int +} + +Table products [headercolor: #FF0000] { + id int [pk] + name varchar + price decimal(10,2) +} + +Ref: products.id < users.favorite_product_id`; + + const diagram = await importDBMLToDiagram(dbmlContent); + + expect(diagram.tables).toHaveLength(2); + + // Check enum conversion + const usersTable = diagram.tables?.find((t) => t.name === 'users'); + const statusField = usersTable?.fields.find((f) => f.name === 'status'); + expect(statusField?.type.id).toBe('varchar'); + + // Check array type conversion + const tagsField = usersTable?.fields.find((f) => f.name === 'tags'); + expect(tagsField?.type.id).toBe('text'); + + // Check that header color was removed + const productsTable = diagram.tables?.find( + (t) => t.name === 'products' + ); + expect(productsTable).toBeDefined(); + expect(productsTable?.name).toBe('products'); + }); + + it('should handle empty or invalid DBML gracefully', async () => { + // Empty DBML + const emptyDiagram = await importDBMLToDiagram(''); + expect(emptyDiagram.tables).toHaveLength(0); + expect(emptyDiagram.relationships).toHaveLength(0); + + // Only comments + const commentDiagram = await importDBMLToDiagram('// Just a comment'); + expect(commentDiagram.tables).toHaveLength(0); + expect(commentDiagram.relationships).toHaveLength(0); + }); + + it('should preserve diagram metadata when importing DBML', async () => { + const dbmlContent = `Table test { + id int [pk] +}`; + const diagram = await importDBMLToDiagram(dbmlContent); + + // Default values + expect(diagram.name).toBe('DBML Import'); + expect(diagram.databaseType).toBe(DatabaseType.GENERIC); + + // These can be overridden by the dialog + diagram.name = 'My Custom Diagram'; + diagram.databaseType = DatabaseType.POSTGRESQL; + + expect(diagram.name).toBe('My Custom Diagram'); + expect(diagram.databaseType).toBe(DatabaseType.POSTGRESQL); + }); +}); diff --git a/src/lib/dbml/dbml-import/dbml-import.ts b/src/lib/dbml/dbml-import/dbml-import.ts index 969a37cef..28401ea84 100644 --- a/src/lib/dbml/dbml-import/dbml-import.ts +++ b/src/lib/dbml/dbml-import/dbml-import.ts @@ -15,6 +15,8 @@ import { type DBCustomType, } from '@/lib/domain/db-custom-type'; +export const defaultDBMLDiagramName = 'DBML Import'; + // Preprocess DBML to handle unsupported features export const preprocessDBML = (content: string): string => { let processed = content; @@ -196,7 +198,7 @@ export const importDBMLToDiagram = async ( if (!dbmlContent.trim()) { return { id: generateDiagramId(), - name: 'DBML Import', + name: defaultDBMLDiagramName, databaseType: options?.databaseType ?? DatabaseType.GENERIC, tables: [], relationships: [], @@ -214,7 +216,7 @@ export const importDBMLToDiagram = async ( if (!sanitizedContent.trim()) { return { id: generateDiagramId(), - name: 'DBML Import', + name: defaultDBMLDiagramName, databaseType: options?.databaseType ?? DatabaseType.GENERIC, tables: [], relationships: [], @@ -229,7 +231,7 @@ export const importDBMLToDiagram = async ( if (!parsedData.schemas || parsedData.schemas.length === 0) { return { id: generateDiagramId(), - name: 'DBML Import', + name: defaultDBMLDiagramName, databaseType: options?.databaseType ?? DatabaseType.GENERIC, tables: [], relationships: [], @@ -734,7 +736,7 @@ export const importDBMLToDiagram = async ( return { id: generateDiagramId(), - name: 'DBML Import', + name: defaultDBMLDiagramName, databaseType: options?.databaseType ?? DatabaseType.GENERIC, tables, relationships, diff --git a/src/lib/dbml/dbml-import/verify-dbml.ts b/src/lib/dbml/dbml-import/verify-dbml.ts new file mode 100644 index 000000000..765b6183e --- /dev/null +++ b/src/lib/dbml/dbml-import/verify-dbml.ts @@ -0,0 +1,52 @@ +import { Parser } from '@dbml/core'; +import { preprocessDBML, sanitizeDBML } from './dbml-import'; +import type { DBMLError } from './dbml-import-error'; +import { parseDBMLError } from './dbml-import-error'; + +export const verifyDBML = ( + content: string +): + | { + hasError: true; + error: unknown; + parsedError?: DBMLError; + errorText: string; + } + | { + hasError: false; + } => { + try { + const preprocessedContent = preprocessDBML(content); + const sanitizedContent = sanitizeDBML(preprocessedContent); + const parser = new Parser(); + parser.parse(sanitizedContent, 'dbmlv2'); + } catch (e) { + const parsedError = parseDBMLError(e); + if (parsedError) { + return { + hasError: true, + parsedError: parsedError, + error: e, + errorText: parsedError.message, + }; + } else { + if (e instanceof Error) { + return { + hasError: true, + error: e, + errorText: e.message, + }; + } + + return { + hasError: true, + error: e, + errorText: JSON.stringify(e), + }; + } + } + + return { + hasError: false, + }; +}; diff --git a/src/lib/import-method/__tests__/detect-import-type.test.ts b/src/lib/import-method/__tests__/detect-import-type.test.ts new file mode 100644 index 000000000..cdca4b266 --- /dev/null +++ b/src/lib/import-method/__tests__/detect-import-type.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { detectImportMethod } from '../detect-import-method'; + +describe('detectImportMethod', () => { + describe('DBML detection', () => { + it('should detect DBML with Table definition', () => { + const content = `Table users { + id int [pk] + name varchar +}`; + expect(detectImportMethod(content)).toBe('dbml'); + }); + + it('should detect DBML with Ref definition', () => { + const content = `Table posts { + user_id int +} + +Ref: posts.user_id > users.id`; + expect(detectImportMethod(content)).toBe('dbml'); + }); + + it('should detect DBML with pk attribute', () => { + const content = `id integer [pk]`; + expect(detectImportMethod(content)).toBe('dbml'); + }); + + it('should detect DBML with ref attribute', () => { + const content = `user_id int [ref: > users.id]`; + expect(detectImportMethod(content)).toBe('dbml'); + }); + + it('should detect DBML with Enum definition', () => { + const content = `Enum status { + active + inactive +}`; + expect(detectImportMethod(content)).toBe('dbml'); + }); + + it('should detect DBML with TableGroup', () => { + const content = `TableGroup commerce { + users + orders +}`; + expect(detectImportMethod(content)).toBe('dbml'); + }); + + it('should detect DBML with Note', () => { + const content = `Note project_note { + 'This is a note about the project' +}`; + expect(detectImportMethod(content)).toBe('dbml'); + }); + + it('should prioritize DBML over SQL when both patterns exist', () => { + const content = `CREATE TABLE test (id int); +Table users { + id int [pk] +}`; + expect(detectImportMethod(content)).toBe('dbml'); + }); + }); + + describe('SQL DDL detection', () => { + it('should detect CREATE TABLE statement', () => { + const content = `CREATE TABLE users ( + id INT PRIMARY KEY, + name VARCHAR(255) +);`; + expect(detectImportMethod(content)).toBe('ddl'); + }); + + it('should detect ALTER TABLE statement', () => { + const content = `ALTER TABLE users ADD COLUMN email VARCHAR(255);`; + expect(detectImportMethod(content)).toBe('ddl'); + }); + + it('should detect DROP TABLE statement', () => { + const content = `DROP TABLE IF EXISTS users;`; + expect(detectImportMethod(content)).toBe('ddl'); + }); + + it('should detect CREATE INDEX statement', () => { + const content = `CREATE INDEX idx_users_email ON users(email);`; + expect(detectImportMethod(content)).toBe('ddl'); + }); + + it('should detect multiple DDL statements', () => { + const content = `CREATE TABLE users (id INT); +CREATE TABLE posts (id INT); +ALTER TABLE posts ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);`; + expect(detectImportMethod(content)).toBe('ddl'); + }); + + it('should detect DDL case-insensitively', () => { + const content = `create table users (id int);`; + expect(detectImportMethod(content)).toBe('ddl'); + }); + }); + + describe('JSON detection', () => { + it('should detect JSON object', () => { + const content = `{ + "tables": [], + "relationships": [] +}`; + expect(detectImportMethod(content)).toBe('query'); + }); + + it('should detect JSON array', () => { + const content = `[ + {"name": "users"}, + {"name": "posts"} +]`; + expect(detectImportMethod(content)).toBe('query'); + }); + + it('should detect minified JSON', () => { + const content = `{"tables":[],"relationships":[]}`; + expect(detectImportMethod(content)).toBe('query'); + }); + + it('should detect JSON with whitespace', () => { + const content = ` { + "data": true +} `; + expect(detectImportMethod(content)).toBe('query'); + }); + }); + + describe('edge cases', () => { + it('should return null for empty content', () => { + expect(detectImportMethod('')).toBeNull(); + expect(detectImportMethod(' ')).toBeNull(); + expect(detectImportMethod('\n\n')).toBeNull(); + }); + + it('should return null for unrecognized content', () => { + const content = `This is just some random text +that doesn't match any pattern`; + expect(detectImportMethod(content)).toBeNull(); + }); + + it('should handle content with special characters', () => { + const content = `Table users { + name varchar // Special chars: áéíóú +}`; + expect(detectImportMethod(content)).toBe('dbml'); + }); + + it('should handle malformed JSON gracefully', () => { + const content = `{ "incomplete": `; + expect(detectImportMethod(content)).toBeNull(); + }); + }); +}); diff --git a/src/lib/import-method/detect-import-method.ts b/src/lib/import-method/detect-import-method.ts new file mode 100644 index 000000000..dcff8be92 --- /dev/null +++ b/src/lib/import-method/detect-import-method.ts @@ -0,0 +1,59 @@ +import type { ImportMethod } from './import-method'; + +export const detectImportMethod = (content: string): ImportMethod | null => { + if (!content || content.trim().length === 0) return null; + + const upperContent = content.toUpperCase(); + + // Check for DBML patterns first (case sensitive) + const dbmlPatterns = [ + /^Table\s+\w+\s*{/m, + /^Ref:\s*\w+/m, + /^Enum\s+\w+\s*{/m, + /^TableGroup\s+/m, + /^Note\s+\w+\s*{/m, + /\[pk\]/, + /\[ref:\s*[<>-]/, + ]; + + const hasDBMLPatterns = dbmlPatterns.some((pattern) => + pattern.test(content) + ); + if (hasDBMLPatterns) return 'dbml'; + + // Common SQL DDL keywords + const ddlKeywords = [ + 'CREATE TABLE', + 'ALTER TABLE', + 'DROP TABLE', + 'CREATE INDEX', + 'CREATE VIEW', + 'CREATE PROCEDURE', + 'CREATE FUNCTION', + 'CREATE SCHEMA', + 'CREATE DATABASE', + ]; + + // Check for SQL DDL patterns + const hasDDLKeywords = ddlKeywords.some((keyword) => + upperContent.includes(keyword) + ); + if (hasDDLKeywords) return 'ddl'; + + // Check if it looks like JSON + try { + // Just check structure, don't need full parse for detection + if ( + (content.trim().startsWith('{') && content.trim().endsWith('}')) || + (content.trim().startsWith('[') && content.trim().endsWith(']')) + ) { + return 'query'; + } + } catch (error) { + // Not valid JSON, might be partial + console.error('Error detecting content type:', error); + } + + // If we can't confidently detect, return null + return null; +}; diff --git a/src/lib/import-method/import-method.ts b/src/lib/import-method/import-method.ts new file mode 100644 index 000000000..b068d2958 --- /dev/null +++ b/src/lib/import-method/import-method.ts @@ -0,0 +1 @@ +export type ImportMethod = 'query' | 'ddl' | 'dbml'; From e6783a89cc8e5d0876b9490be13719d50a2834fc Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Mon, 15 Sep 2025 19:24:35 +0300 Subject: [PATCH 006/156] refactor: remove dedicated DBML import dialog (#907) * refactor: remove dedicated DBML import dialog and unify import flow through ImportDatabaseDialog * fix --------- Co-authored-by: Guy Ben-Aharon --- src/context/dialog-context/dialog-context.tsx | 9 - .../dialog-context/dialog-provider.tsx | 16 - .../import-database/import-database.tsx | 20 +- .../create-diagram-dialog.tsx | 7 +- .../import-dbml-dialog/import-dbml-dialog.tsx | 355 ------------------ .../editor-page/top-navbar/menu/menu.tsx | 4 - 6 files changed, 20 insertions(+), 391 deletions(-) delete mode 100644 src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx diff --git a/src/context/dialog-context/dialog-context.tsx b/src/context/dialog-context/dialog-context.tsx index a574e4c7f..0992b21ad 100644 --- a/src/context/dialog-context/dialog-context.tsx +++ b/src/context/dialog-context/dialog-context.tsx @@ -7,7 +7,6 @@ import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/expor import type { ExportDiagramDialogProps } from '@/dialogs/export-diagram-dialog/export-diagram-dialog'; import type { ImportDiagramDialogProps } from '@/dialogs/import-diagram-dialog/import-diagram-dialog'; import type { CreateRelationshipDialogProps } from '@/dialogs/create-relationship-dialog/create-relationship-dialog'; -import type { ImportDBMLDialogProps } from '@/dialogs/import-dbml-dialog/import-dbml-dialog'; import type { OpenDiagramDialogProps } from '@/dialogs/open-diagram-dialog/open-diagram-dialog'; import type { CreateDiagramDialogProps } from '@/dialogs/create-diagram-dialog/create-diagram-dialog'; @@ -67,12 +66,6 @@ export interface DialogContext { params: Omit ) => void; closeImportDiagramDialog: () => void; - - // Import DBML dialog - openImportDBMLDialog: ( - params?: Omit - ) => void; - closeImportDBMLDialog: () => void; } export const dialogContext = createContext({ @@ -96,6 +89,4 @@ export const dialogContext = createContext({ closeExportDiagramDialog: emptyFn, openImportDiagramDialog: emptyFn, closeImportDiagramDialog: emptyFn, - openImportDBMLDialog: emptyFn, - closeImportDBMLDialog: emptyFn, }); diff --git a/src/context/dialog-context/dialog-provider.tsx b/src/context/dialog-context/dialog-provider.tsx index 35a834c5f..00745bdee 100644 --- a/src/context/dialog-context/dialog-provider.tsx +++ b/src/context/dialog-context/dialog-provider.tsx @@ -20,8 +20,6 @@ import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/expor import { ExportImageDialog } from '@/dialogs/export-image-dialog/export-image-dialog'; import { ExportDiagramDialog } from '@/dialogs/export-diagram-dialog/export-diagram-dialog'; import { ImportDiagramDialog } from '@/dialogs/import-diagram-dialog/import-diagram-dialog'; -import type { ImportDBMLDialogProps } from '@/dialogs/import-dbml-dialog/import-dbml-dialog'; -import { ImportDBMLDialog } from '@/dialogs/import-dbml-dialog/import-dbml-dialog'; export const DialogProvider: React.FC = ({ children, @@ -132,11 +130,6 @@ export const DialogProvider: React.FC = ({ const [openImportDiagramDialog, setOpenImportDiagramDialog] = useState(false); - // Import DBML dialog - const [openImportDBMLDialog, setOpenImportDBMLDialog] = useState(false); - const [importDBMLDialogParams, setImportDBMLDialogParams] = - useState>(); - return ( = ({ openImportDiagramDialog: () => setOpenImportDiagramDialog(true), closeImportDiagramDialog: () => setOpenImportDiagramDialog(false), - openImportDBMLDialog: (params) => { - setImportDBMLDialogParams(params); - setOpenImportDBMLDialog(true); - }, - closeImportDBMLDialog: () => setOpenImportDBMLDialog(false), }} > {children} @@ -204,10 +192,6 @@ export const DialogProvider: React.FC = ({ /> - ); }; diff --git a/src/dialogs/common/import-database/import-database.tsx b/src/dialogs/common/import-database/import-database.tsx index 3382350a0..e69a81620 100644 --- a/src/dialogs/common/import-database/import-database.tsx +++ b/src/dialogs/common/import-database/import-database.tsx @@ -46,6 +46,10 @@ import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-lang import type { ImportMethod } from '@/lib/import-method/import-method'; import { detectImportMethod } from '@/lib/import-method/detect-import-method'; import { verifyDBML } from '@/lib/dbml/dbml-import/verify-dbml'; +import { + clearErrorHighlight, + highlightErrorLine, +} from '@/components/code-snippet/dbml/utils'; const calculateContentSizeMB = (content: string): number => { return content.length / (1024 * 1024); // Convert to MB @@ -93,6 +97,7 @@ export const ImportDatabase: React.FC = ({ const { effectiveTheme } = useTheme(); const [errorMessage, setErrorMessage] = useState(''); const editorRef = useRef(null); + const decorationsCollection = useRef(); const pasteDisposableRef = useRef(null); const { t } = useTranslation(); @@ -107,6 +112,10 @@ export const ImportDatabase: React.FC = ({ const [isAutoFixing, setIsAutoFixing] = useState(false); const [showAutoFixButton, setShowAutoFixButton] = useState(false); + const clearDecorations = useCallback(() => { + clearErrorHighlight(decorationsCollection.current); + }, []); + useEffect(() => { setScriptResult(''); setErrorMessage(''); @@ -115,6 +124,7 @@ export const ImportDatabase: React.FC = ({ // Check if the ddl or dbml is valid useEffect(() => { + clearDecorations(); if (importMethod === 'query') { setSqlValidation(null); setShowAutoFixButton(false); @@ -145,6 +155,12 @@ export const ImportDatabase: React.FC = ({ if (validateResponse.parsedError) { errorMsg = validateResponse.parsedError.message; line = validateResponse.parsedError.line; + highlightErrorLine({ + error: validateResponse.parsedError, + model: editorRef.current?.getModel(), + editorDecorationsCollection: + decorationsCollection.current, + }); } setSqlValidation({ @@ -192,7 +208,7 @@ export const ImportDatabase: React.FC = ({ setErrorMessage(result.error); } }); - }, [importMethod, scriptResult, databaseType]); + }, [importMethod, scriptResult, databaseType, clearDecorations]); // Check if the script result is a valid JSON useEffect(() => { @@ -320,6 +336,8 @@ export const ImportDatabase: React.FC = ({ const handleEditorDidMount = useCallback( (editor: editor.IStandaloneCodeEditor) => { editorRef.current = editor; + decorationsCollection.current = + editor.createDecorationsCollection(); // Cleanup previous disposable if it exists if (pasteDisposableRef.current) { diff --git a/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx b/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx index ffc92875d..5faee1665 100644 --- a/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx +++ b/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx @@ -39,7 +39,7 @@ export const CreateDiagramDialog: React.FC = ({ const [databaseType, setDatabaseType] = useState( DatabaseType.GENERIC ); - const { closeCreateDiagramDialog, openImportDBMLDialog } = useDialog(); + const { closeCreateDiagramDialog } = useDialog(); const { updateConfig } = useConfig(); const [scriptResult, setScriptResult] = useState(''); const [databaseEdition, setDatabaseEdition] = useState< @@ -165,10 +165,6 @@ export const CreateDiagramDialog: React.FC = ({ await updateConfig({ config: { defaultDiagramId: diagram.id } }); closeCreateDiagramDialog(); navigate(`/diagrams/${diagram.id}`); - setTimeout( - () => openImportDBMLDialog({ withCreateEmptyDiagram: true }), - 700 - ); }, [ databaseType, addDiagram, @@ -177,7 +173,6 @@ export const CreateDiagramDialog: React.FC = ({ navigate, updateConfig, diagramNumber, - openImportDBMLDialog, ]); const importNewDiagramOrFilterTables = useCallback(async () => { diff --git a/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx b/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx deleted file mode 100644 index 1bfd2be84..000000000 --- a/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import React, { - useCallback, - useEffect, - useState, - Suspense, - useRef, -} from 'react'; -import type * as monaco from 'monaco-editor'; -import { useDialog } from '@/hooks/use-dialog'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogInternalContent, - DialogTitle, -} from '@/components/dialog/dialog'; -import { Button } from '@/components/button/button'; -import type { BaseDialogProps } from '../common/base-dialog-props'; -import { useTranslation } from 'react-i18next'; -import { Editor } from '@/components/code-snippet/code-snippet'; -import { useTheme } from '@/hooks/use-theme'; -import { AlertCircle } from 'lucide-react'; -import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import'; -import { useChartDB } from '@/hooks/use-chartdb'; -import { useCanvas } from '@/hooks/use-canvas'; -import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language'; -import type { DBTable } from '@/lib/domain/db-table'; -import { useToast } from '@/components/toast/use-toast'; -import { Spinner } from '@/components/spinner/spinner'; -import { debounce } from '@/lib/utils'; -import { - clearErrorHighlight, - highlightErrorLine, -} from '@/components/code-snippet/dbml/utils'; -import { verifyDBML } from '@/lib/dbml/dbml-import/verify-dbml'; - -export interface ImportDBMLDialogProps extends BaseDialogProps { - withCreateEmptyDiagram?: boolean; -} - -export const ImportDBMLDialog: React.FC = ({ - dialog, - withCreateEmptyDiagram, -}) => { - const { t } = useTranslation(); - const initialDBML = `// Use DBML to define your database structure -// Simple Blog System with Comments Example - -Table users { - id integer [primary key] - name varchar - email varchar -} - -Table posts { - id integer [primary key] - title varchar - content text - user_id integer - created_at timestamp -} - -Table comments { - id integer [primary key] - content text - post_id integer - user_id integer - created_at timestamp -} - -// Relationships -Ref: posts.user_id > users.id // Each post belongs to one user -Ref: comments.post_id > posts.id // Each comment belongs to one post -Ref: comments.user_id > users.id // Each comment is written by one user`; - - const [dbmlContent, setDBMLContent] = useState(initialDBML); - const { closeImportDBMLDialog } = useDialog(); - const [errorMessage, setErrorMessage] = useState(); - const { effectiveTheme } = useTheme(); - const { toast } = useToast(); - const { - addTables, - addRelationships, - tables, - relationships, - removeTables, - removeRelationships, - databaseType, - } = useChartDB(); - const { reorderTables } = useCanvas(); - const [reorder, setReorder] = useState(false); - const editorRef = useRef(); - const decorationsCollection = - useRef(); - - const handleEditorDidMount = ( - editor: monaco.editor.IStandaloneCodeEditor - ) => { - editorRef.current = editor; - decorationsCollection.current = editor.createDecorationsCollection(); - }; - - useEffect(() => { - if (reorder) { - reorderTables({ - updateHistory: false, - }); - setReorder(false); - } - }, [reorder, reorderTables]); - - const clearDecorations = useCallback(() => { - clearErrorHighlight(decorationsCollection.current); - }, []); - - const validateDBML = useCallback( - async (content: string) => { - // Clear previous errors - setErrorMessage(undefined); - clearDecorations(); - - if (!content.trim()) { - return; - } - - const validateResponse = verifyDBML(content); - - if (validateResponse.hasError) { - if (validateResponse.parsedError) { - const parsedError = validateResponse.parsedError; - setErrorMessage( - t('import_dbml_dialog.error.description') + - ` (1 error found - in line ${parsedError.line})` - ); - highlightErrorLine({ - error: parsedError, - model: editorRef.current?.getModel(), - editorDecorationsCollection: - decorationsCollection.current, - }); - } else { - setErrorMessage(validateResponse.errorText); - } - } - }, - [clearDecorations, t] - ); - - const debouncedValidateRef = useRef<((value: string) => void) | null>(null); - - // Set up debounced validation - useEffect(() => { - debouncedValidateRef.current = debounce((value: string) => { - validateDBML(value); - }, 500); - - return () => { - debouncedValidateRef.current = null; - }; - }, [validateDBML]); - - // Trigger validation when content changes - useEffect(() => { - if (debouncedValidateRef.current) { - debouncedValidateRef.current(dbmlContent); - } - }, [dbmlContent]); - - useEffect(() => { - if (!dialog.open) { - setErrorMessage(undefined); - clearDecorations(); - setDBMLContent(initialDBML); - } - }, [dialog.open, initialDBML, clearDecorations]); - - const handleImport = useCallback(async () => { - if (!dbmlContent.trim() || errorMessage) return; - - try { - const importedDiagram = await importDBMLToDiagram(dbmlContent, { - databaseType, - }); - const tableIdsToRemove = tables - .filter((table) => - importedDiagram.tables?.some( - (t: DBTable) => - t.name === table.name && t.schema === table.schema - ) - ) - .map((table) => table.id); - // Find relationships that need to be removed - const relationshipIdsToRemove = relationships - .filter((relationship) => { - const sourceTable = tables.find( - (table: DBTable) => - table.id === relationship.sourceTableId - ); - const targetTable = tables.find( - (table: DBTable) => - table.id === relationship.targetTableId - ); - if (!sourceTable || !targetTable) return true; - const replacementSourceTable = importedDiagram.tables?.find( - (table: DBTable) => - table.name === sourceTable.name && - table.schema === sourceTable.schema - ); - const replacementTargetTable = importedDiagram.tables?.find( - (table: DBTable) => - table.name === targetTable.name && - table.schema === targetTable.schema - ); - return replacementSourceTable || replacementTargetTable; - }) - .map((relationship) => relationship.id); - - // Remove existing items - await Promise.all([ - removeTables(tableIdsToRemove, { updateHistory: false }), - removeRelationships(relationshipIdsToRemove, { - updateHistory: false, - }), - ]); - - // Add new items - await Promise.all([ - addTables(importedDiagram.tables ?? [], { - updateHistory: false, - }), - addRelationships(importedDiagram.relationships ?? [], { - updateHistory: false, - }), - ]); - setReorder(true); - closeImportDBMLDialog(); - } catch (e) { - toast({ - title: t('import_dbml_dialog.error.title'), - variant: 'destructive', - description: ( - <> -
{t('import_dbml_dialog.error.description')}
- {e instanceof Error ? e.message : JSON.stringify(e)} - - ), - }); - } - }, [ - dbmlContent, - closeImportDBMLDialog, - tables, - relationships, - removeTables, - removeRelationships, - addTables, - addRelationships, - errorMessage, - toast, - setReorder, - t, - databaseType, - ]); - - return ( - { - if (!open) { - closeImportDBMLDialog(); - } - }} - > - - - - {withCreateEmptyDiagram - ? t('import_dbml_dialog.example_title') - : t('import_dbml_dialog.title')} - - - {t('import_dbml_dialog.description')} - - - - }> - setDBMLContent(value || '')} - language="dbml" - onMount={handleEditorDidMount} - theme={ - effectiveTheme === 'dark' - ? 'dbml-dark' - : 'dbml-light' - } - beforeMount={setupDBMLLanguage} - options={{ - minimap: { enabled: false }, - scrollBeyondLastLine: false, - automaticLayout: true, - glyphMargin: true, - lineNumbers: 'on', - scrollbar: { - vertical: 'visible', - horizontal: 'visible', - }, - }} - className="size-full" - /> - - - -
-
- - - - {errorMessage ? ( -
- - - - {errorMessage || - t( - 'import_dbml_dialog.error.description' - )} - -
- ) : null} -
- -
-
-
-
- ); -}; diff --git a/src/pages/editor-page/top-navbar/menu/menu.tsx b/src/pages/editor-page/top-navbar/menu/menu.tsx index 1ffef3920..f7f9dcd93 100644 --- a/src/pages/editor-page/top-navbar/menu/menu.tsx +++ b/src/pages/editor-page/top-navbar/menu/menu.tsx @@ -46,7 +46,6 @@ export const Menu: React.FC = () => { openExportImageDialog, openExportDiagramDialog, openImportDiagramDialog, - openImportDBMLDialog, } = useDialog(); const { showAlert } = useAlert(); const { setTheme, theme } = useTheme(); @@ -185,9 +184,6 @@ export const Menu: React.FC = () => { .json - openImportDBMLDialog()}> - .dbml - From d3dbf41894d74f0ffce9afe3bd810f065aa53017 Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Mon, 15 Sep 2025 20:50:44 +0300 Subject: [PATCH 007/156] fix(sqlite): improve parser to handle tables without column types and fix column detection (#914) --- .../instructions/ddl-instructions.tsx | 4 +- .../sqlite/__tests__/sqlite-import.test.ts | 178 ++++++++++++ .../dialect-importers/sqlite/sqlite.ts | 270 ++++++++++++------ 3 files changed, 361 insertions(+), 91 deletions(-) create mode 100644 src/lib/data/sql-import/dialect-importers/sqlite/__tests__/sqlite-import.test.ts diff --git a/src/dialogs/common/import-database/instructions-section/instructions/ddl-instructions.tsx b/src/dialogs/common/import-database/instructions-section/instructions/ddl-instructions.tsx index 1f02c5182..8d4935265 100644 --- a/src/dialogs/common/import-database/instructions-section/instructions/ddl-instructions.tsx +++ b/src/dialogs/common/import-database/instructions-section/instructions/ddl-instructions.tsx @@ -43,8 +43,8 @@ const DDLInstructionsMap: Record = { }, { text: 'Execute the following command in your terminal:', - code: `sqlite3 \n.dump > `, - example: `sqlite3 my_db.db\n.dump > schema_export.sql`, + code: `sqlite3 \n".schema" > `, + example: `sqlite3 my_db.db\n".schema" > schema_export.sql`, }, { text: 'Open the exported SQL file, copy its contents, and paste them here.', diff --git a/src/lib/data/sql-import/dialect-importers/sqlite/__tests__/sqlite-import.test.ts b/src/lib/data/sql-import/dialect-importers/sqlite/__tests__/sqlite-import.test.ts new file mode 100644 index 000000000..5c1ae6731 --- /dev/null +++ b/src/lib/data/sql-import/dialect-importers/sqlite/__tests__/sqlite-import.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest'; +import { fromSQLite } from '../sqlite'; + +describe('SQLite Import Tests', () => { + it('should parse SQLite script with sqlite_sequence table and all relationships', async () => { + const sql = ` +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + age INTEGER +); +CREATE TABLE sqlite_sequence(name,seq); +CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + price REAL +); +CREATE TABLE user_products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + purchased_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (product_id) REFERENCES products(id) +); + `; + + const result = await fromSQLite(sql); + + // ============= CHECK TOTAL COUNTS ============= + // Should have exactly 4 tables + expect(result.tables).toHaveLength(4); + + // Should have exactly 2 foreign key relationships + expect(result.relationships).toHaveLength(2); + + // ============= CHECK USERS TABLE ============= + const usersTable = result.tables.find((t) => t.name === 'users'); + expect(usersTable).toBeDefined(); + expect(usersTable?.columns).toHaveLength(3); // id, name, age + + // Check each column in users table + expect(usersTable?.columns[0]).toMatchObject({ + name: 'id', + type: 'INTEGER', + primaryKey: true, + increment: true, + nullable: false, + }); + expect(usersTable?.columns[1]).toMatchObject({ + name: 'name', + type: 'TEXT', + primaryKey: false, + nullable: true, + }); + expect(usersTable?.columns[2]).toMatchObject({ + name: 'age', + type: 'INTEGER', + primaryKey: false, + nullable: true, + }); + + // ============= CHECK SQLITE_SEQUENCE TABLE ============= + const sqliteSequenceTable = result.tables.find( + (t) => t.name === 'sqlite_sequence' + ); + expect(sqliteSequenceTable).toBeDefined(); + expect(sqliteSequenceTable?.columns).toHaveLength(2); // name, seq + + // Check columns in sqlite_sequence table + expect(sqliteSequenceTable?.columns[0]).toMatchObject({ + name: 'name', + type: 'TEXT', // Should default to TEXT when no type specified + primaryKey: false, + nullable: true, + }); + expect(sqliteSequenceTable?.columns[1]).toMatchObject({ + name: 'seq', + type: 'TEXT', // Should default to TEXT when no type specified + primaryKey: false, + nullable: true, + }); + + // ============= CHECK PRODUCTS TABLE ============= + const productsTable = result.tables.find((t) => t.name === 'products'); + expect(productsTable).toBeDefined(); + expect(productsTable?.columns).toHaveLength(3); // id, name, price + + // Check each column in products table + expect(productsTable?.columns[0]).toMatchObject({ + name: 'id', + type: 'INTEGER', + primaryKey: true, + increment: true, + nullable: false, + }); + expect(productsTable?.columns[1]).toMatchObject({ + name: 'name', + type: 'TEXT', + primaryKey: false, + nullable: true, + }); + expect(productsTable?.columns[2]).toMatchObject({ + name: 'price', + type: 'REAL', + primaryKey: false, + nullable: true, + }); + + // ============= CHECK USER_PRODUCTS TABLE ============= + const userProductsTable = result.tables.find( + (t) => t.name === 'user_products' + ); + expect(userProductsTable).toBeDefined(); + expect(userProductsTable?.columns).toHaveLength(4); // id, user_id, product_id, purchased_at + + // Check each column in user_products table + expect(userProductsTable?.columns[0]).toMatchObject({ + name: 'id', + type: 'INTEGER', + primaryKey: true, + increment: true, + nullable: false, + }); + expect(userProductsTable?.columns[1]).toMatchObject({ + name: 'user_id', + type: 'INTEGER', + primaryKey: false, + nullable: false, // NOT NULL constraint + }); + expect(userProductsTable?.columns[2]).toMatchObject({ + name: 'product_id', + type: 'INTEGER', + primaryKey: false, + nullable: false, // NOT NULL constraint + }); + expect(userProductsTable?.columns[3]).toMatchObject({ + name: 'purchased_at', + type: 'TIMESTAMP', // DATETIME should map to TIMESTAMP + primaryKey: false, + nullable: true, + default: 'CURRENT_TIMESTAMP', + }); + + // ============= CHECK FOREIGN KEY RELATIONSHIPS ============= + // FK 1: user_products.user_id -> users.id + const userIdFK = result.relationships.find( + (r) => + r.sourceTable === 'user_products' && + r.sourceColumn === 'user_id' && + r.targetTable === 'users' && + r.targetColumn === 'id' + ); + expect(userIdFK).toBeDefined(); + expect(userIdFK).toMatchObject({ + sourceTable: 'user_products', + sourceColumn: 'user_id', + targetTable: 'users', + targetColumn: 'id', + }); + + // FK 2: user_products.product_id -> products.id + const productIdFK = result.relationships.find( + (r) => + r.sourceTable === 'user_products' && + r.sourceColumn === 'product_id' && + r.targetTable === 'products' && + r.targetColumn === 'id' + ); + expect(productIdFK).toBeDefined(); + expect(productIdFK).toMatchObject({ + sourceTable: 'user_products', + sourceColumn: 'product_id', + targetTable: 'products', + targetColumn: 'id', + }); + }); +}); diff --git a/src/lib/data/sql-import/dialect-importers/sqlite/sqlite.ts b/src/lib/data/sql-import/dialect-importers/sqlite/sqlite.ts index bd40500cb..aa03e51de 100644 --- a/src/lib/data/sql-import/dialect-importers/sqlite/sqlite.ts +++ b/src/lib/data/sql-import/dialect-importers/sqlite/sqlite.ts @@ -32,11 +32,11 @@ export async function fromSQLite(sqlContent: string): Promise { const tableMap: Record = {}; // Maps table name to its ID try { - // SPECIAL HANDLING: Direct line-by-line parser for SQLite DDL - // This ensures we preserve the exact data types from the original DDL + // SPECIAL HANDLING: Direct regex-based parser for SQLite DDL + // This ensures we handle all SQLite-specific syntax including tables without types const directlyParsedTables = parseCreateTableStatements(sqlContent); - // Check if we successfully parsed tables directly + // Always try direct parsing first as it's more reliable for SQLite if (directlyParsedTables.length > 0) { // Map the direct parsing results to the expected SQLParserResult format directlyParsedTables.forEach((table) => { @@ -56,8 +56,19 @@ export async function fromSQLite(sqlContent: string): Promise { // Process foreign keys using the regex approach findForeignKeysUsingRegex(sqlContent, tableMap, relationships); - // Return the result - return { tables, relationships }; + // Create placeholder tables for any missing referenced tables + addPlaceholderTablesForFKReferences( + tables, + relationships, + tableMap + ); + + // Filter out any invalid relationships + const validRelationships = relationships.filter((rel) => { + return isValidForeignKeyRelationship(rel, tables); + }); + + return { tables, relationships: validRelationships }; } // Preprocess SQL to handle SQLite quoted identifiers @@ -130,101 +141,182 @@ function parseCreateTableStatements(sqlContent: string): { columns: SQLColumn[]; }[] = []; - // Split SQL content into lines - const lines = sqlContent.split('\n'); - - let currentTable: { name: string; columns: SQLColumn[] } | null = null; - let inCreateTable = false; + // Remove comments before processing + const cleanedSQL = sqlContent + .split('\n') + .map((line) => { + const commentIndex = line.indexOf('--'); + if (commentIndex >= 0) { + return line.substring(0, commentIndex); + } + return line; + }) + .join('\n'); - // Process each line - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); + // Match all CREATE TABLE statements including those without column definitions + const createTableRegex = + /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?\s*\(([^;]+?)\)\s*;/gis; + let match; - // Skip empty lines and comments - if (!line || line.startsWith('--')) { - continue; - } + while ((match = createTableRegex.exec(cleanedSQL)) !== null) { + const tableName = match[1]; + const tableBody = match[2].trim(); - // Check for CREATE TABLE statement - if (line.toUpperCase().startsWith('CREATE TABLE')) { - // Extract table name - const tableNameMatch = - /CREATE\s+TABLE\s+(?:if\s+not\s+exists\s+)?["'`]?(\w+)["'`]?/i.exec( - line - ); - if (tableNameMatch && tableNameMatch[1]) { - inCreateTable = true; - currentTable = { - name: tableNameMatch[1], - columns: [], - }; - } - } - // Check for end of CREATE TABLE statement - else if (inCreateTable && line.includes(');')) { - if (currentTable) { - tables.push(currentTable); + const table: { name: string; columns: SQLColumn[] } = { + name: tableName, + columns: [], + }; + + // Special case: sqlite_sequence or tables with columns but no types + if (tableName === 'sqlite_sequence' || !tableBody.includes(' ')) { + // Parse simple column list without types (e.g., "name,seq") + const simpleColumns = tableBody.split(',').map((col) => col.trim()); + for (const colName of simpleColumns) { + if ( + colName && + !colName.toUpperCase().startsWith('FOREIGN KEY') && + !colName.toUpperCase().startsWith('PRIMARY KEY') && + !colName.toUpperCase().startsWith('UNIQUE') && + !colName.toUpperCase().startsWith('CHECK') && + !colName.toUpperCase().startsWith('CONSTRAINT') + ) { + table.columns.push({ + name: colName.replace(/["'`]/g, ''), + type: 'TEXT', // Default to TEXT for untyped columns + nullable: true, + primaryKey: false, + unique: false, + default: '', + increment: false, + }); + } } - inCreateTable = false; - currentTable = null; - } - // Process column definitions inside CREATE TABLE - else if (inCreateTable && currentTable && line.includes('"')) { - // Column line pattern optimized for user's DDL format - const columnPattern = /\s*["'`](\w+)["'`]\s+([A-Za-z0-9_]+)(.+)?/i; - const match = columnPattern.exec(line); - - if (match) { - const columnName = match[1]; - const rawType = match[2].toUpperCase(); - const restOfLine = match[3] || ''; - - // Determine column properties - const isPrimaryKey = restOfLine - .toUpperCase() - .includes('PRIMARY KEY'); - const isNotNull = restOfLine.toUpperCase().includes('NOT NULL'); - const isUnique = restOfLine.toUpperCase().includes('UNIQUE'); - - // Extract default value - let defaultValue = ''; - const defaultMatch = /DEFAULT\s+([^,\s)]+)/i.exec(restOfLine); - if (defaultMatch) { - defaultValue = defaultMatch[1]; + } else { + // Parse normal table with typed columns + // Split by commas not inside parentheses + const columnDefs = []; + let current = ''; + let parenDepth = 0; + + for (let i = 0; i < tableBody.length; i++) { + const char = tableBody[i]; + if (char === '(') parenDepth++; + else if (char === ')') parenDepth--; + else if (char === ',' && parenDepth === 0) { + columnDefs.push(current.trim()); + current = ''; + continue; } + current += char; + } + if (current.trim()) { + columnDefs.push(current.trim()); + } - // Map to appropriate SQLite storage class - let columnType = rawType; - if (rawType === 'INTEGER' || rawType === 'INT') { - columnType = 'INTEGER'; - } else if ( - ['REAL', 'FLOAT', 'DOUBLE', 'NUMERIC', 'DECIMAL'].includes( - rawType - ) - ) { - columnType = 'REAL'; - } else if (rawType === 'BLOB' || rawType === 'BINARY') { - columnType = 'BLOB'; - } else if ( - ['TIMESTAMP', 'DATETIME', 'DATE'].includes(rawType) + for (const columnDef of columnDefs) { + const line = columnDef.trim(); + + // Skip constraints + if ( + line.toUpperCase().startsWith('FOREIGN KEY') || + line.toUpperCase().startsWith('PRIMARY KEY') || + line.toUpperCase().startsWith('UNIQUE') || + line.toUpperCase().startsWith('CHECK') || + line.toUpperCase().startsWith('CONSTRAINT') ) { - columnType = 'TIMESTAMP'; - } else { - columnType = 'TEXT'; + continue; } - // Add column to the table - currentTable.columns.push({ - name: columnName, - type: columnType, - nullable: !isNotNull, - primaryKey: isPrimaryKey, - unique: isUnique || isPrimaryKey, - default: defaultValue, - increment: isPrimaryKey && columnType === 'INTEGER', - }); + // Parse column: handle both quoted and unquoted identifiers + // Pattern: [quotes]columnName[quotes] dataType [constraints] + const columnPattern = /^["'`]?([\w]+)["'`]?\s+(\w+)(.*)$/i; + const columnMatch = columnPattern.exec(line); + + if (columnMatch) { + const columnName = columnMatch[1]; + const rawType = columnMatch[2].toUpperCase(); + const restOfLine = columnMatch[3] || ''; + const upperRest = restOfLine.toUpperCase(); + + // Determine column properties + const isPrimaryKey = upperRest.includes('PRIMARY KEY'); + const isAutoIncrement = upperRest.includes('AUTOINCREMENT'); + const isNotNull = + upperRest.includes('NOT NULL') || isPrimaryKey; + const isUnique = + upperRest.includes('UNIQUE') || isPrimaryKey; + + // Extract default value + let defaultValue = ''; + const defaultMatch = /DEFAULT\s+([^,)]+)/i.exec(restOfLine); + if (defaultMatch) { + defaultValue = defaultMatch[1].trim(); + // Remove quotes if present + if ( + (defaultValue.startsWith("'") && + defaultValue.endsWith("'")) || + (defaultValue.startsWith('"') && + defaultValue.endsWith('"')) + ) { + defaultValue = defaultValue.slice(1, -1); + } + } + + // Map to appropriate SQLite storage class + let columnType = rawType; + if (rawType === 'INTEGER' || rawType === 'INT') { + columnType = 'INTEGER'; + } else if ( + [ + 'REAL', + 'FLOAT', + 'DOUBLE', + 'NUMERIC', + 'DECIMAL', + ].includes(rawType) + ) { + columnType = 'REAL'; + } else if (rawType === 'BLOB' || rawType === 'BINARY') { + columnType = 'BLOB'; + } else if ( + ['TIMESTAMP', 'DATETIME', 'DATE', 'TIME'].includes( + rawType + ) + ) { + columnType = 'TIMESTAMP'; + } else if ( + ['TEXT', 'VARCHAR', 'CHAR', 'CLOB', 'STRING'].includes( + rawType + ) || + rawType.startsWith('VARCHAR') || + rawType.startsWith('CHAR') + ) { + columnType = 'TEXT'; + } else { + // Default to TEXT for unknown types + columnType = 'TEXT'; + } + + // Add column to the table + table.columns.push({ + name: columnName, + type: columnType, + nullable: !isNotNull, + primaryKey: isPrimaryKey, + unique: isUnique, + default: defaultValue, + increment: + isPrimaryKey && + isAutoIncrement && + columnType === 'INTEGER', + }); + } } } + + if (table.columns.length > 0 || tableName === 'sqlite_sequence') { + tables.push(table); + } } return tables; From bdc41c0b74d9d9918e7b6cd2152fa07c0c58ce60 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Tue, 16 Sep 2025 10:15:42 +0300 Subject: [PATCH 008/156] fix: trigger edit table on canvas from context menu (#919) --- .../canvas/table-node/table-node-context-menu.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx b/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx index 931a3e4f2..3343b06f9 100644 --- a/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx @@ -13,6 +13,7 @@ import { Copy, Pencil, Trash2, Workflow } from 'lucide-react'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useDialog } from '@/hooks/use-dialog'; +import { useCanvas } from '@/hooks/use-canvas'; export interface TableNodeContextMenuProps { table: DBTable; @@ -22,10 +23,11 @@ export const TableNodeContextMenu: React.FC< React.PropsWithChildren > = ({ children, table }) => { const { removeTable, readonly, createTable } = useChartDB(); - const { openTableFromSidebar } = useLayout(); + const { closeAllTablesInSidebar } = useLayout(); const { t } = useTranslation(); const { isMd: isDesktop } = useBreakpoint('md'); const { openCreateRelationshipDialog } = useDialog(); + const { setEditTableModeTable } = useCanvas(); const duplicateTableHandler = useCallback(() => { const clonedTable = cloneTable(table); @@ -38,8 +40,13 @@ export const TableNodeContextMenu: React.FC< }, [createTable, table]); const editTableHandler = useCallback(() => { - openTableFromSidebar(table.id); - }, [openTableFromSidebar, table.id]); + if (readonly) { + return; + } + + closeAllTablesInSidebar(); + setEditTableModeTable({ tableId: table.id }); + }, [table.id, setEditTableModeTable, closeAllTablesInSidebar, readonly]); const removeTableHandler = useCallback(() => { removeTable(table.id); From d09379e8be0fa3c83ca77ff62ae815fe4db9869b Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Tue, 16 Sep 2025 14:57:38 +0300 Subject: [PATCH 009/156] feat: add area context menu and UI improvements (#918) * feat: add area context menu and UI improvements - Add right-click context menu for areas with edit/delete options - Add pencil icon on hover for diagram name - Add dynamic input width for diagram name - Keep existing useClickAway behavior * fix --------- Co-authored-by: Guy Ben-Aharon --- .../area-node/area-node-context-menu.tsx | 54 +++++++ .../canvas/area-node/area-node.tsx | 147 ++++++++++-------- .../editor-page/top-navbar/diagram-name.tsx | 56 +++++-- 3 files changed, 179 insertions(+), 78 deletions(-) create mode 100644 src/pages/editor-page/canvas/area-node/area-node-context-menu.tsx diff --git a/src/pages/editor-page/canvas/area-node/area-node-context-menu.tsx b/src/pages/editor-page/canvas/area-node/area-node-context-menu.tsx new file mode 100644 index 000000000..c1aeca67f --- /dev/null +++ b/src/pages/editor-page/canvas/area-node/area-node-context-menu.tsx @@ -0,0 +1,54 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/components/context-menu/context-menu'; +import { useBreakpoint } from '@/hooks/use-breakpoint'; +import { useChartDB } from '@/hooks/use-chartdb'; +import type { Area } from '@/lib/domain/area'; +import { Pencil, Trash2 } from 'lucide-react'; +import React, { useCallback } from 'react'; + +export interface AreaNodeContextMenuProps { + area: Area; + onEditName?: () => void; +} + +export const AreaNodeContextMenu: React.FC< + React.PropsWithChildren +> = ({ children, area, onEditName }) => { + const { removeArea, readonly } = useChartDB(); + const { isMd: isDesktop } = useBreakpoint('md'); + + const removeAreaHandler = useCallback(() => { + removeArea(area.id); + }, [removeArea, area.id]); + + if (!isDesktop || readonly) { + return <>{children}; + } + return ( + + {children} + + {onEditName ? ( + + Edit Area Name + + + ) : null} + + Delete Area + + + + + ); +}; diff --git a/src/pages/editor-page/canvas/area-node/area-node.tsx b/src/pages/editor-page/canvas/area-node/area-node.tsx index 1164dbf44..4d3dd1865 100644 --- a/src/pages/editor-page/canvas/area-node/area-node.tsx +++ b/src/pages/editor-page/canvas/area-node/area-node.tsx @@ -12,9 +12,10 @@ import { } from '@/components/tooltip/tooltip'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; -import { Check, GripVertical } from 'lucide-react'; +import { Check, GripVertical, Pencil } from 'lucide-react'; import { Button } from '@/components/button/button'; import { useLayout } from '@/hooks/use-layout'; +import { AreaNodeContextMenu } from './area-node-context-menu'; export type AreaNodeType = Node< { @@ -62,79 +63,93 @@ export const AreaNode: React.FC> = React.memo( }; return ( -
{ - if (e.detail === 2) { - openAreaInEditor(); - } - }} + setEditMode(true)} > - {!readonly ? ( - - ) : null} -
-
- +
{ + if (e.detail === 2) { + openAreaInEditor(); + } + }} + > + {!readonly ? ( + + ) : null} +
+
+ - {editMode && !readonly ? ( -
- e.stopPropagation()} - onChange={(e) => - setAreaName(e.target.value) - } - className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70" - /> + {editMode && !readonly ? ( +
+ e.stopPropagation()} + onChange={(e) => + setAreaName(e.target.value) + } + className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70" + /> + +
+ ) : !readonly ? ( + + +
+ {area.name} +
+
+ + {t('tool_tips.double_click_to_edit')} + +
+ ) : ( +
+ {area.name} +
+ )} + {!editMode && !readonly && ( -
- ) : !readonly ? ( - - -
- {area.name} -
-
- - {t('tool_tips.double_click_to_edit')} - -
- ) : ( -
- {area.name} -
- )} + )} +
+
-
-
+ ); } ); diff --git a/src/pages/editor-page/top-navbar/diagram-name.tsx b/src/pages/editor-page/top-navbar/diagram-name.tsx index a3a6fb0ae..d381f4626 100644 --- a/src/pages/editor-page/top-navbar/diagram-name.tsx +++ b/src/pages/editor-page/top-navbar/diagram-name.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Button } from '@/components/button/button'; -import { Check } from 'lucide-react'; +import { Check, Pencil } from 'lucide-react'; import { Input } from '@/components/input/input'; import { useChartDB } from '@/hooks/use-chartdb'; import { useClickAway, useKeyPressEvent } from 'react-use'; @@ -32,22 +32,39 @@ export const DiagramName: React.FC = () => { }, [diagramName]); const editDiagramName = useCallback(() => { - if (!editMode) return; if (editedDiagramName.trim()) { updateDiagramName(editedDiagramName.trim()); } setEditMode(false); - }, [editedDiagramName, updateDiagramName, editMode]); + }, [editedDiagramName, updateDiagramName]); + // Handle click outside to save and exit edit mode useClickAway(inputRef, editDiagramName); + useKeyPressEvent('Enter', editDiagramName); - const enterEditMode = ( - event: React.MouseEvent - ) => { - event.stopPropagation(); - setEditMode(true); - }; + useEffect(() => { + if (editMode) { + // Small delay to ensure the input is rendered + const timeoutId = setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, 50); // Slightly longer delay to ensure DOM is ready + + return () => clearTimeout(timeoutId); + } + }, [editMode]); + + const enterEditMode = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + setEditedDiagramName(diagramName); + setEditMode(true); + }, + [diagramName] + ); return (
@@ -80,11 +97,16 @@ export const DiagramName: React.FC = () => { onChange={(e) => setEditedDiagramName(e.target.value) } - className="ml-1 h-7 focus-visible:ring-0" + className="h-7 max-w-[300px] focus-visible:ring-0" + style={{ + width: `${ + editedDiagramName.length * 8 + 30 + }px`, + }} /> )}
From d6ba4a40749d85d2703f120600df4345dab3c561 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Wed, 17 Sep 2025 11:36:29 +0300 Subject: [PATCH 010/156] fix: import dbml set pk field unique (#920) --- .../dbml/dbml-import/__tests__/cases/1.dbml | 3 + .../dbml/dbml-import/__tests__/cases/1.json | 1 + .../dbml/dbml-import/__tests__/cases/2.dbml | 7 + .../dbml/dbml-import/__tests__/cases/2.json | 1 + .../__tests__/dbml-import-cases.test.ts | 298 ++++++++++++++++++ src/lib/dbml/dbml-import/dbml-import.ts | 2 +- 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/lib/dbml/dbml-import/__tests__/cases/1.dbml create mode 100644 src/lib/dbml/dbml-import/__tests__/cases/1.json create mode 100644 src/lib/dbml/dbml-import/__tests__/cases/2.dbml create mode 100644 src/lib/dbml/dbml-import/__tests__/cases/2.json create mode 100644 src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts diff --git a/src/lib/dbml/dbml-import/__tests__/cases/1.dbml b/src/lib/dbml/dbml-import/__tests__/cases/1.dbml new file mode 100644 index 000000000..38aee97f9 --- /dev/null +++ b/src/lib/dbml/dbml-import/__tests__/cases/1.dbml @@ -0,0 +1,3 @@ +Table "public"."table_3"{ + "id" bigint [pk] +} \ No newline at end of file diff --git a/src/lib/dbml/dbml-import/__tests__/cases/1.json b/src/lib/dbml/dbml-import/__tests__/cases/1.json new file mode 100644 index 000000000..f0f82fa20 --- /dev/null +++ b/src/lib/dbml/dbml-import/__tests__/cases/1.json @@ -0,0 +1 @@ +{"id":"mqqwkkodxt6p","name":"Diagram 3","createdAt":"2025-09-16T15:33:25.300Z","updatedAt":"2025-09-16T15:33:31.563Z","databaseType":"postgresql","tables":[{"id":"loyxg6mafzos5u971uirjs3zh","name":"table_3","schema":"","order":0,"fields":[{"id":"29e2p9bom0uxo1n0a9ze5auuy","name":"id","type":{"name":"bigint","id":"bigint","usageLevel":2},"nullable":true,"primaryKey":true,"unique":true,"createdAt":1758036805300}],"indexes":[{"id":"5gf0aeptch1uk1bxv0x89wxxe","name":"pk_table_3_id","fieldIds":["29e2p9bom0uxo1n0a9ze5auuy"],"unique":true,"isPrimaryKey":true,"createdAt":1758036811564}],"x":0,"y":0,"color":"#8eb7ff","isView":false,"createdAt":1758036805300,"diagramId":"mqqwkkodxt6p"}],"relationships":[],"dependencies":[],"areas":[],"customTypes":[]} \ No newline at end of file diff --git a/src/lib/dbml/dbml-import/__tests__/cases/2.dbml b/src/lib/dbml/dbml-import/__tests__/cases/2.dbml new file mode 100644 index 000000000..8f5950c92 --- /dev/null +++ b/src/lib/dbml/dbml-import/__tests__/cases/2.dbml @@ -0,0 +1,7 @@ +Table "table_3" { + "id" bigint [pk] +} + +Table "table_2" { + "id" bigint [pk, not null, ref: < "table_3"."id"] +} diff --git a/src/lib/dbml/dbml-import/__tests__/cases/2.json b/src/lib/dbml/dbml-import/__tests__/cases/2.json new file mode 100644 index 000000000..07d0e5ffc --- /dev/null +++ b/src/lib/dbml/dbml-import/__tests__/cases/2.json @@ -0,0 +1 @@ +{"id":"mqqwkkod6r09","name":"Diagram 10","createdAt":"2025-09-16T15:47:40.655Z","updatedAt":"2025-09-16T15:47:50.179Z","databaseType":"postgresql","tables":[{"id":"6xbco4ihmuiyv2heuw9fggbgx","name":"table_3","schema":"","order":0,"fields":[{"id":"rxftaey7uxvq5qg6ix1hbak1c","name":"id","type":{"name":"bigint","id":"bigint","usageLevel":2},"nullable":true,"primaryKey":true,"unique":true,"createdAt":1758037660654}],"indexes":[{"id":"vsyjjaq2l58urkh9qm2g9hqhd","name":"pk_table_3_id","fieldIds":["rxftaey7uxvq5qg6ix1hbak1c"],"unique":true,"isPrimaryKey":true,"createdAt":1758037660654}],"x":0,"y":0,"color":"#8eb7ff","isView":false,"createdAt":1758037660654,"diagramId":"mqqwkkod6r09"},{"id":"klu6k5ntddcxfdsu0fsfcwbiw","name":"table_2","schema":"","order":1,"fields":[{"id":"qq2415tivmtvun8vd727d9mr2","name":"id","type":{"name":"bigint","id":"bigint","usageLevel":2},"nullable":false,"primaryKey":true,"unique":true,"createdAt":1758037660655}],"indexes":[{"id":"cvv7sgmq07i9y54lz9a97nah5","name":"pk_table_2_id","fieldIds":["qq2415tivmtvun8vd727d9mr2"],"unique":true,"isPrimaryKey":true,"createdAt":1758037660655}],"x":300,"y":0,"color":"#8eb7ff","isView":false,"createdAt":1758037660655,"diagramId":"mqqwkkod6r09"}],"relationships":[{"id":"yw2pbcumsabuncc6rjnp3n87t","name":"table_3_id_table_2_id","sourceSchema":"","targetSchema":"","sourceTableId":"6xbco4ihmuiyv2heuw9fggbgx","targetTableId":"klu6k5ntddcxfdsu0fsfcwbiw","sourceFieldId":"rxftaey7uxvq5qg6ix1hbak1c","targetFieldId":"qq2415tivmtvun8vd727d9mr2","sourceCardinality":"one","targetCardinality":"one","createdAt":1758037660655,"diagramId":"mqqwkkod6r09"}],"dependencies":[],"areas":[],"customTypes":[]} \ No newline at end of file diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts new file mode 100644 index 000000000..ef8f5dec1 --- /dev/null +++ b/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect } from 'vitest'; +import { importDBMLToDiagram } from '../dbml-import'; +import * as fs from 'fs'; +import * as path from 'path'; +import { DatabaseType } from '@/lib/domain/database-type'; +import type { DBTable } from '@/lib/domain/db-table'; +import type { DBField } from '@/lib/domain/db-field'; +import type { DBRelationship } from '@/lib/domain/db-relationship'; +import { defaultSchemas } from '@/lib/data/default-schemas'; + +// Type for field map entries +interface FieldMapEntry { + tableName: string; + fieldName: string; +} + +// Helper function to compare field properties (excluding IDs and timestamps) +function expectFieldsMatch( + actualFields: DBField[], + expectedFields: DBField[] +): void { + expect(actualFields).toHaveLength(expectedFields.length); + + for (let i = 0; i < actualFields.length; i++) { + const actual = actualFields[i]; + const expected = expectedFields[i]; + + // Compare field properties (excluding ID and createdAt) + expect(actual.name).toBe(expected.name); + + // Handle type comparison (could be string or object with name property) + if (typeof expected.type === 'object' && expected.type?.name) { + expect(actual.type?.name).toBe(expected.type.name); + } else if (typeof expected.type === 'string') { + expect(actual.type?.name).toBe(expected.type); + } + + // Boolean flags with defaults + expect(actual.primaryKey).toBe(expected.primaryKey || false); + expect(actual.unique).toBe(expected.unique || false); + expect(actual.nullable).toBe(expected.nullable ?? true); + + // Optional boolean flag + if (expected.increment !== undefined) { + expect(actual.increment).toBe(expected.increment); + } + + // Optional string/number properties + if (expected.characterMaximumLength !== undefined) { + expect(actual.characterMaximumLength).toBe( + expected.characterMaximumLength + ); + } + + if (expected.precision !== undefined) { + expect(actual.precision).toBe(expected.precision); + } + + if (expected.scale !== undefined) { + expect(actual.scale).toBe(expected.scale); + } + + if (expected.default !== undefined) { + expect(actual.default).toBe(expected.default); + } + + if (expected.collation !== undefined) { + expect(actual.collation).toBe(expected.collation); + } + + if (expected.comments !== undefined) { + expect(actual.comments).toBe(expected.comments); + } + } +} + +// Helper function to compare table properties (excluding IDs) +function expectTablesMatch( + actualTables: DBTable[], + expectedTables: DBTable[], + databaseType: DatabaseType +): void { + expect(actualTables).toHaveLength(expectedTables.length); + + // Sort tables by name for consistent comparison + const sortedActual = [...actualTables].sort((a, b) => + a.name.localeCompare(b.name) + ); + const sortedExpected = [...expectedTables].sort((a, b) => + a.name.localeCompare(b.name) + ); + + for (let i = 0; i < sortedActual.length; i++) { + const actual = sortedActual[i]; + const expected = sortedExpected[i]; + + // Compare table properties (excluding ID and position) + expect(actual.name).toBe(expected.name); + + // Schema comparison - handle differences in how schemas are represented + if (expected.schema) { + const defaultSchema = defaultSchemas[databaseType]; + if (defaultSchema && expected.schema === defaultSchema) { + // DBML parser might not include default schema or might handle it differently + expect( + actual.schema === expected.schema || + actual.schema === '' || + actual.schema === undefined + ).toBeTruthy(); + } else { + expect(actual.schema).toBe(expected.schema); + } + } + + // Compare fields + expectFieldsMatch(actual.fields, expected.fields); + + // Check indexes exist for tables with primary keys + const hasPrimaryKeyField = actual.fields.some((f) => f.primaryKey); + if (hasPrimaryKeyField) { + expect(actual.indexes).toBeDefined(); + expect(actual.indexes.length).toBeGreaterThan(0); + + const pkIndex = actual.indexes.find((idx) => idx.isPrimaryKey); + expect(pkIndex).toBeDefined(); + expect(pkIndex?.unique).toBe(true); + } + + // Check comments if present + if (expected.comments !== undefined) { + expect(actual.comments).toBe(expected.comments); + } + } +} + +// Helper function to compare relationships (excluding IDs) +function expectRelationshipsMatch( + actualRelationships: DBRelationship[], + expectedRelationships: DBRelationship[], + actualTables: DBTable[], + expectedTables: DBTable[] +): void { + expect(actualRelationships).toHaveLength(expectedRelationships.length); + + // Create lookup maps for table and field names by ID + const expectedTableMap = new Map(expectedTables.map((t) => [t.id, t.name])); + const actualTableMap = new Map(actualTables.map((t) => [t.id, t.name])); + + const expectedFieldMap = new Map(); + const actualFieldMap = new Map(); + + expectedTables.forEach((table) => { + table.fields.forEach((field) => { + expectedFieldMap.set(field.id, { + tableName: table.name, + fieldName: field.name, + }); + }); + }); + + actualTables.forEach((table) => { + table.fields.forEach((field) => { + actualFieldMap.set(field.id, { + tableName: table.name, + fieldName: field.name, + }); + }); + }); + + // Sort relationships for consistent comparison + const sortRelationships = ( + rels: DBRelationship[], + tableMap: Map, + fieldMap: Map + ) => { + return [...rels].sort((a, b) => { + const aSourceTable = tableMap.get(a.sourceTableId) || ''; + const bSourceTable = tableMap.get(b.sourceTableId) || ''; + const aTargetTable = tableMap.get(a.targetTableId) || ''; + const bTargetTable = tableMap.get(b.targetTableId) || ''; + + const tableCompare = + aSourceTable.localeCompare(bSourceTable) || + aTargetTable.localeCompare(bTargetTable); + if (tableCompare !== 0) return tableCompare; + + const aSourceField = fieldMap.get(a.sourceFieldId)?.fieldName || ''; + const bSourceField = fieldMap.get(b.sourceFieldId)?.fieldName || ''; + const aTargetField = fieldMap.get(a.targetFieldId)?.fieldName || ''; + const bTargetField = fieldMap.get(b.targetFieldId)?.fieldName || ''; + + return ( + aSourceField.localeCompare(bSourceField) || + aTargetField.localeCompare(bTargetField) + ); + }); + }; + + const sortedActual = sortRelationships( + actualRelationships, + actualTableMap, + actualFieldMap + ); + const sortedExpected = sortRelationships( + expectedRelationships, + expectedTableMap, + expectedFieldMap + ); + + for (let i = 0; i < sortedActual.length; i++) { + const actual = sortedActual[i]; + const expected = sortedExpected[i]; + + // Get table and field names for comparison + const actualSourceTable = actualTableMap.get(actual.sourceTableId); + const actualTargetTable = actualTableMap.get(actual.targetTableId); + const expectedSourceTable = expectedTableMap.get( + expected.sourceTableId + ); + const expectedTargetTable = expectedTableMap.get( + expected.targetTableId + ); + + const actualSourceField = actualFieldMap.get(actual.sourceFieldId); + const actualTargetField = actualFieldMap.get(actual.targetFieldId); + const expectedSourceField = expectedFieldMap.get( + expected.sourceFieldId + ); + const expectedTargetField = expectedFieldMap.get( + expected.targetFieldId + ); + + // Compare relationship by table and field names + expect(actualSourceTable).toBe(expectedSourceTable); + expect(actualTargetTable).toBe(expectedTargetTable); + expect(actualSourceField?.fieldName).toBe( + expectedSourceField?.fieldName + ); + expect(actualTargetField?.fieldName).toBe( + expectedTargetField?.fieldName + ); + + // Compare cardinality + expect(actual.sourceCardinality).toBe(expected.sourceCardinality); + expect(actual.targetCardinality).toBe(expected.targetCardinality); + + // Compare relationship name if present + if (expected.name !== undefined) { + expect(actual.name).toBe(expected.name); + } + } +} + +// Main test helper function +async function testDBMLImportCase(caseNumber: string): Promise { + // Read the DBML file + const dbmlPath = path.join(__dirname, 'cases', `${caseNumber}.dbml`); + const dbmlContent = fs.readFileSync(dbmlPath, 'utf-8'); + + // Read the expected JSON file + const jsonPath = path.join(__dirname, 'cases', `${caseNumber}.json`); + const jsonContent = fs.readFileSync(jsonPath, 'utf-8'); + const expectedData = JSON.parse(jsonContent); + + // Import DBML to diagram + const result = await importDBMLToDiagram(dbmlContent, { + databaseType: expectedData.databaseType || DatabaseType.POSTGRESQL, + }); + + // Check basic diagram properties + expect(result.name).toBe('DBML Import'); // Name is always 'DBML Import' + expect(result.databaseType).toBe(expectedData.databaseType); + + // Check tables and fields + expectTablesMatch( + result.tables || [], + expectedData.tables || [], + expectedData.databaseType || DatabaseType.POSTGRESQL + ); + + // Check relationships + expectRelationshipsMatch( + result.relationships || [], + expectedData.relationships || [], + result.tables || [], + expectedData.tables || [] + ); +} + +describe('DBML Import cases', () => { + it('should handle case 1 - simple table with pk and unique', async () => { + await testDBMLImportCase('1'); + }); + + it('should handle case 2 - tables with relationships', async () => { + await testDBMLImportCase('2'); + }); +}); diff --git a/src/lib/dbml/dbml-import/dbml-import.ts b/src/lib/dbml/dbml-import/dbml-import.ts index 28401ea84..9ac25d849 100644 --- a/src/lib/dbml/dbml-import/dbml-import.ts +++ b/src/lib/dbml/dbml-import/dbml-import.ts @@ -482,7 +482,7 @@ export const importDBMLToDiagram = async ( }), nullable: !field.not_null, primaryKey: field.pk || false, - unique: field.unique || false, + unique: field.unique || field.pk || false, // Primary keys are always unique createdAt: Date.now(), characterMaximumLength: field.characterMaximumLength, precision: field.precision, From 26dc299cd28e9890d191c13f84a15ac38ae48b11 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Wed, 17 Sep 2025 14:27:53 +0300 Subject: [PATCH 011/156] fix: dbml export renaming fields bug (#921) --- src/lib/data/sql-export/export-sql-script.ts | 8 +- src/lib/dbml/dbml-export/dbml-export.ts | 164 +----------------- .../__tests__/dbml-schema-handling.test.ts | 9 +- 3 files changed, 20 insertions(+), 161 deletions(-) diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index 4f149ef9f..826b0f799 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -469,10 +469,16 @@ export const exportBaseSQL = ({ .join(', '); if (fieldNames) { - const indexName = + const rawIndexName = table.schema && !isDBMLFlow ? `${table.schema}_${index.name}` : index.name; + // Quote index name if it contains special characters + // For DBML flow, also quote if contains special characters + const needsQuoting = /[^a-zA-Z0-9_]/.test(rawIndexName); + const indexName = needsQuoting + ? `"${rawIndexName}"` + : rawIndexName; sqlScript += `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName} ON ${tableName} (${fieldNames});\n`; } }); diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index 0c0a8935f..4ee8d014f 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -3,7 +3,6 @@ import { exportBaseSQL } from '@/lib/data/sql-export/export-sql-script'; import type { Diagram } from '@/lib/domain/diagram'; import { DatabaseType } from '@/lib/domain/database-type'; import type { DBTable } from '@/lib/domain/db-table'; -import { type DBField } from '@/lib/domain/db-field'; import type { DBCustomType } from '@/lib/domain/db-custom-type'; import { DBCustomTypeKind } from '@/lib/domain/db-custom-type'; @@ -502,24 +501,6 @@ const convertToInlineRefs = (dbml: string): string => { return cleanedDbml; }; -// Function to check for DBML reserved keywords -const isDBMLKeyword = (name: string): boolean => { - const keywords = new Set([ - 'YES', - 'NO', - 'TRUE', - 'FALSE', - 'NULL', // DBML reserved keywords (boolean literals) - ]); - return keywords.has(name.toUpperCase()); -}; - -// Function to check for SQL keywords (add more if needed) -const isSQLKeyword = (name: string): boolean => { - const keywords = new Set(['CASE', 'ORDER', 'GROUP', 'FROM', 'TO', 'USER']); // Common SQL keywords - return keywords.has(name.toUpperCase()); -}; - // Function to remove duplicate relationships from the diagram const deduplicateRelationships = (diagram: Diagram): Diagram => { if (!diagram.relationships) return diagram; @@ -543,48 +524,6 @@ const deduplicateRelationships = (diagram: Diagram): Diagram => { }; }; -// Function to append comment statements for renamed tables and fields -const appendRenameComments = ( - baseScript: string, - sqlRenamedTables: Map, - fieldRenames: Array<{ - table: string; - originalName: string; - newName: string; - }>, - finalDiagramForExport: Diagram -): string => { - let script = baseScript; - - // Append COMMENTS for tables renamed due to SQL keywords - sqlRenamedTables.forEach((originalName, newName) => { - const escapedOriginal = originalName.replace(/'/g, "\\'"); - // Find the table to get its schema - const table = finalDiagramForExport.tables?.find( - (t) => t.name === newName - ); - const tableIdentifier = table?.schema - ? `"${table.schema}"."${newName}"` - : `"${newName}"`; - script += `\nCOMMENT ON TABLE ${tableIdentifier} IS 'Original name was "${escapedOriginal}" (renamed due to SQL keyword conflict).';`; - }); - - // Append COMMENTS for fields renamed due to SQL keyword conflicts - fieldRenames.forEach(({ table, originalName, newName }) => { - const escapedOriginal = originalName.replace(/'/g, "\\'"); - // Find the table to get its schema - const tableObj = finalDiagramForExport.tables?.find( - (t) => t.name === table - ); - const tableIdentifier = tableObj?.schema - ? `"${tableObj.schema}"."${table}"` - : `"${table}"`; - script += `\nCOMMENT ON COLUMN ${tableIdentifier}."${newName}" IS 'Original name was "${escapedOriginal}" (renamed due to SQL keyword conflict).';`; - }); - - return script; -}; - // Fix DBML formatting to ensure consistent display of char and varchar types const normalizeCharTypeFormat = (dbml: string): string => { // Replace "char (N)" with "char(N)" to match varchar's formatting @@ -843,105 +782,33 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { // Sanitize field names ('from'/'to' in 'relation' table) const cleanDiagram = fixProblematicFieldNames(filteredDiagram); - // --- Final sanitization and renaming pass --- - // Only rename keywords for PostgreSQL/SQLite - // For other databases, we'll wrap problematic names in quotes instead - const shouldRenameKeywords = - diagram.databaseType === DatabaseType.POSTGRESQL || - diagram.databaseType === DatabaseType.SQLITE; - const sqlRenamedTables = new Map(); - const fieldRenames: Array<{ - table: string; - originalName: string; - newName: string; - }> = []; - + // Simplified processing - just handle duplicate field names const processTable = (table: DBTable) => { - const originalName = table.name; - let safeTableName = originalName; - - // If name contains spaces or special characters, wrap in quotes - if (/[^\w]/.test(originalName)) { - safeTableName = `"${originalName.replace(/"/g, '\\"')}"`; - } - - // Rename table if it's a keyword (PostgreSQL/SQLite only) - if ( - shouldRenameKeywords && - (isDBMLKeyword(originalName) || isSQLKeyword(originalName)) - ) { - const newName = `${originalName}_table`; - sqlRenamedTables.set(newName, originalName); - safeTableName = /[^\w]/.test(newName) - ? `"${newName.replace(/"/g, '\\"')}"` - : newName; - } - // For other databases, just quote DBML keywords - else if (!shouldRenameKeywords && isDBMLKeyword(originalName)) { - safeTableName = `"${originalName.replace(/"/g, '\\"')}"`; - } - const fieldNameCounts = new Map(); const processedFields = table.fields.map((field) => { - let finalSafeName = field.name; - - // If field name contains spaces or special characters, wrap in quotes - if (/[^\w]/.test(field.name)) { - finalSafeName = `"${field.name.replace(/"/g, '\\"')}"`; - } - // Handle duplicate field names const count = fieldNameCounts.get(field.name) || 0; if (count > 0) { const newName = `${field.name}_${count + 1}`; - finalSafeName = /[^\w]/.test(newName) - ? `"${newName.replace(/"/g, '\\"')}"` - : newName; + return { + ...field, + name: newName, + }; } fieldNameCounts.set(field.name, count + 1); - - // Create sanitized field - const sanitizedField: DBField = { - ...field, - name: finalSafeName, - }; - - // Rename field if it's a keyword (PostgreSQL/SQLite only) - if ( - shouldRenameKeywords && - (isDBMLKeyword(field.name) || isSQLKeyword(field.name)) - ) { - const newFieldName = `${field.name}_field`; - fieldRenames.push({ - table: safeTableName, - originalName: field.name, - newName: newFieldName, - }); - sanitizedField.name = /[^\w]/.test(newFieldName) - ? `"${newFieldName.replace(/"/g, '\\"')}"` - : newFieldName; - } - // For other databases, just quote DBML keywords - else if (!shouldRenameKeywords && isDBMLKeyword(field.name)) { - sanitizedField.name = `"${field.name.replace(/"/g, '\\"')}"`; - } - - return sanitizedField; + return field; }); return { ...table, - name: safeTableName, fields: processedFields, indexes: (table.indexes || []) .filter((index) => !index.isPrimaryKey) // Filter out PK indexes as they're handled separately .map((index) => ({ ...index, - name: index.name - ? /[^\w]/.test(index.name) - ? `"${index.name.replace(/"/g, '\\"')}"` - : index.name - : `idx_${Math.random().toString(36).substring(2, 8)}`, + name: + index.name || + `idx_${Math.random().toString(36).substring(2, 8)}`, })), }; }; @@ -979,19 +846,6 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { baseScript = sanitizeSQLforDBML(baseScript); - // Append comments for renamed tables and fields (PostgreSQL/SQLite only) - if ( - shouldRenameKeywords && - (sqlRenamedTables.size > 0 || fieldRenames.length > 0) - ) { - baseScript = appendRenameComments( - baseScript, - sqlRenamedTables, - fieldRenames, - finalDiagramForExport - ); - } - standard = fixArrayTypes( normalizeCharTypeFormat( fixMultilineTableNames( diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-schema-handling.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-schema-handling.test.ts index 27c26f4cd..e811ac601 100644 --- a/src/lib/dbml/dbml-import/__tests__/dbml-schema-handling.test.ts +++ b/src/lib/dbml/dbml-import/__tests__/dbml-schema-handling.test.ts @@ -180,7 +180,7 @@ describe('DBML Schema Handling - Fantasy Realm Database', () => { expect(artifactsTable?.schema).toBe(''); // No schema = empty string }); - it('should rename reserved keywords for PostgreSQL', async () => { + it('should handle reserved keywords for PostgreSQL', async () => { const dbmlContent = ` Table "magic_items" { "id" bigint [pk] @@ -197,10 +197,9 @@ describe('DBML Schema Handling - Fantasy Realm Database', () => { const exported = generateDBMLFromDiagram(diagram); - // For PostgreSQL, keywords should be renamed in export - expect(exported.standardDbml).toContain('Order_field'); - expect(exported.standardDbml).toContain('Yes_field'); - expect(exported.standardDbml).toContain('No_field'); + expect(exported.standardDbml).toContain('Order'); + expect(exported.standardDbml).toContain('Yes'); + expect(exported.standardDbml).toContain('No'); }); }); From bc82f9d6a8fe4de2f7e0fc465e0a20c5dbf8f41d Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Wed, 17 Sep 2025 15:08:44 +0300 Subject: [PATCH 012/156] fix: dbml export default time bug (#922) --- src/lib/data/sql-export/export-sql-script.ts | 10 +++ .../dbml/dbml-export/__tests__/cases/4.dbml | 7 ++ .../dbml/dbml-export/__tests__/cases/4.json | 1 + .../__tests__/export-sql-dbml-cases.test.ts | 72 +++++++------------ 4 files changed, 42 insertions(+), 48 deletions(-) create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/4.dbml create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/4.json diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index 826b0f799..fc4a043c1 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -381,6 +381,16 @@ export const exportBaseSQL = ({ fieldDefault = `now()`; } + // Fix CURRENT_DATE() for PostgreSQL in DBML flow - PostgreSQL uses CURRENT_DATE without parentheses + if ( + isDBMLFlow && + targetDatabaseType === DatabaseType.POSTGRESQL + ) { + if (fieldDefault.toUpperCase() === 'CURRENT_DATE()') { + fieldDefault = 'CURRENT_DATE'; + } + } + sqlScript += ` DEFAULT ${fieldDefault}`; } } diff --git a/src/lib/dbml/dbml-export/__tests__/cases/4.dbml b/src/lib/dbml/dbml-export/__tests__/cases/4.dbml new file mode 100644 index 000000000..3b2bcd981 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/4.dbml @@ -0,0 +1,7 @@ +Table "public"."orders" { + "order_id" integer [pk, not null] + "customer_id" integer [not null] + "order_date" date [not null, default: `CURRENT_DATE`] + "total_amount" numeric [not null, default: 0] + "status" varchar(50) [not null, default: 'Pending'] +} diff --git a/src/lib/dbml/dbml-export/__tests__/cases/4.json b/src/lib/dbml/dbml-export/__tests__/cases/4.json new file mode 100644 index 000000000..4157868ef --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/4.json @@ -0,0 +1 @@ +{"id":"6b81a1787207","name":"SQL Import (postgresql)","createdAt":"2025-09-15T08:46:26.747Z","updatedAt":"2025-09-17T11:32:13.876Z","databaseType":"postgresql","tables":[{"id":"5ytf0yj9etpmm7mhmhvpu8kfj","name":"orders","schema":"public","order":1,"fields":[{"id":"w7l77cy9hylvlitdovt4ktdmk","name":"order_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":true,"unique":false,"default":"","createdAt":1757925986747,"increment":true},{"id":"vz7747t5fxrb62v1eepmahv9v","name":"customer_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","createdAt":1757925986747,"increment":false},{"id":"geq9qy6sv4ozl2lg9fvcyzxpf","name":"order_date","type":{"name":"date","id":"date","usageLevel":1},"nullable":false,"primaryKey":false,"unique":false,"default":"CURRENT_DATE()","createdAt":1757925986747,"increment":false},{"id":"z928n7umvpec79t2eif7kmde9","name":"total_amount","type":{"name":"numeric","id":"numeric","fieldAttributes":{"precision":{"max":999,"min":1,"default":10},"scale":{"max":999,"min":0,"default":2}}},"nullable":false,"primaryKey":false,"unique":false,"default":"0","createdAt":1757925986747,"increment":false},{"id":"7bkrd0rp1s17bi1lnle6pesc7","name":"status","type":{"name":"varchar","id":"varchar","fieldAttributes":{"hasCharMaxLength":true},"usageLevel":1},"nullable":false,"primaryKey":false,"unique":false,"default":"'Pending'","createdAt":1757925986747,"increment":false,"characterMaximumLength":"50"}],"indexes":[],"x":113,"y":747,"color":"#8eb7ff","isView":false,"createdAt":1757925986747,"diagramId":"6b81a1787207","parentAreaId":null}],"relationships":[],"dependencies":[],"storageMode":"project","lastProjectSavedAt":"2025-09-17T11:32:13.876Z","areas":[],"creationMethod":"imported","customTypes":[]} \ No newline at end of file diff --git a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts index b4f893176..69bf2bec0 100644 --- a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts +++ b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts @@ -4,64 +4,40 @@ import { generateDBMLFromDiagram } from '../dbml-export'; import * as fs from 'fs'; import * as path from 'path'; -describe('DBML Export - Diagram Case 1 Tests', () => { - it('should handle case 1 diagram', { timeout: 30000 }, async () => { - // Read the JSON file - const jsonPath = path.join(__dirname, 'cases', '1.json'); - const jsonContent = fs.readFileSync(jsonPath, 'utf-8'); +const testCase = (caseNumber: string) => { + // Read the JSON file + const jsonPath = path.join(__dirname, 'cases', `${caseNumber}.json`); + const jsonContent = fs.readFileSync(jsonPath, 'utf-8'); + + // Parse the JSON and convert to diagram + const diagram = diagramFromJSONInput(jsonContent); - // Parse the JSON and convert to diagram - const diagram = diagramFromJSONInput(jsonContent); + // Generate DBML from the diagram + const result = generateDBMLFromDiagram(diagram); + const generatedDBML = result.standardDbml; - // Generate DBML from the diagram - const result = generateDBMLFromDiagram(diagram); - const generatedDBML = result.standardDbml; + // Read the expected DBML file + const dbmlPath = path.join(__dirname, 'cases', `${caseNumber}.dbml`); + const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); - // Read the expected DBML file - const dbmlPath = path.join(__dirname, 'cases', '1.dbml'); - const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); + // Compare the generated DBML with the expected DBML + expect(generatedDBML).toBe(expectedDBML); +}; - // Compare the generated DBML with the expected DBML - expect(generatedDBML).toBe(expectedDBML); +describe('DBML Export cases', () => { + it('should handle case 1 diagram', { timeout: 30000 }, async () => { + testCase('1'); }); it('should handle case 2 diagram', { timeout: 30000 }, async () => { - // Read the JSON file - const jsonPath = path.join(__dirname, 'cases', '2.json'); - const jsonContent = fs.readFileSync(jsonPath, 'utf-8'); - - // Parse the JSON and convert to diagram - const diagram = diagramFromJSONInput(jsonContent); - - // Generate DBML from the diagram - const result = generateDBMLFromDiagram(diagram); - const generatedDBML = result.standardDbml; - - // Read the expected DBML file - const dbmlPath = path.join(__dirname, 'cases', '2.dbml'); - const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); - - // Compare the generated DBML with the expected DBML - expect(generatedDBML).toBe(expectedDBML); + testCase('2'); }); it('should handle case 3 diagram', { timeout: 30000 }, async () => { - // Read the JSON file - const jsonPath = path.join(__dirname, 'cases', '3.json'); - const jsonContent = fs.readFileSync(jsonPath, 'utf-8'); - - // Parse the JSON and convert to diagram - const diagram = diagramFromJSONInput(jsonContent); - - // Generate DBML from the diagram - const result = generateDBMLFromDiagram(diagram); - const generatedDBML = result.standardDbml; - - // Read the expected DBML file - const dbmlPath = path.join(__dirname, 'cases', '3.dbml'); - const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); + testCase('3'); + }); - // Compare the generated DBML with the expected DBML - expect(generatedDBML).toBe(expectedDBML); + it('should handle case 4 diagram', { timeout: 30000 }, async () => { + testCase('4'); }); }); From 99910779789a9c6ef113d06bc3de31e35b9b04d1 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Wed, 17 Sep 2025 18:13:48 +0300 Subject: [PATCH 013/156] fix: handle bidirectional relationships in DBML export (#924) --- .../dbml-export/__tests__/cases/5.inline.dbml | 129 ++++++++++++++++++ .../dbml/dbml-export/__tests__/cases/5.json | 1 + .../__tests__/export-sql-dbml-cases.test.ts | 38 +++++- src/lib/dbml/dbml-export/dbml-export.ts | 17 ++- 4 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/5.json diff --git a/src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml b/src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml new file mode 100644 index 000000000..cd3c75cb9 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml @@ -0,0 +1,129 @@ +Enum "cbhpm_entradas_tipo" { + "grupo" + "subgrupo" + "procedimento" +} + +Enum "cid_entradas_tipo" { + "capitulo" + "agrupamento" + "categoria" + "subcategoria" +} + +Enum "digital_signature_provider" { + "soluti" + "valid" +} + +Enum "impresso_posicao" { + "start" + "center" + "end" +} + +Enum "otp_provider" { + "clinic" + "soluti_bird_id" +} + +Enum "tipo_cobranca" { + "valor" + "porte" +} + +Enum "tipo_contato_movel" { + "celular" + "telefone_residencial" + "telefone_comercial" +} + +Enum "tipo_contrato" { + "trial" + "common" +} + +Enum "tipo_endereco" { + "residencial" + "comercial" + "cobranca" +} + +Enum "tipo_espectro_autista" { + "leve" + "moderado" + "severo" +} + +Enum "tipo_estado_civil" { + "nao_infomado" + "solteiro" + "casado" + "divorciado" + "viuvo" +} + +Enum "tipo_etnia" { + "nao_infomado" + "branca" + "preta" + "parda" + "amarela" + "indigena" +} + +Enum "tipo_excecao" { + "bloqueio" + "compromisso" +} + +Enum "tipo_metodo_reajuste" { + "percentual" + "valor" +} + +Enum "tipo_pessoa" { + "fisica" + "juridica" +} + +Enum "tipo_procedimento" { + "consulta" + "exame_laboratorial" + "exame_imagem" + "procedimento_clinico" + "procedimento_cirurgico" + "terapia" + "outros" +} + +Enum "tipo_relacionamento" { + "pai" + "mae" + "conjuge" + "filho_a" + "tutor_legal" + "contato_emergencia" + "outro" +} + +Enum "tipo_sexo" { + "nao_infomado" + "masculino" + "feminino" + "intersexo" +} + +Enum "tipo_status_agendamento" { + "em espera" + "faltou" + "ok" +} + +Table "public"."organizacao_cfg_impressos" { + "id_organizacao" integer [pk, not null, ref: < "public"."organizacao"."id"] +} + +Table "public"."organizacao" { + "id" integer [pk, not null] +} diff --git a/src/lib/dbml/dbml-export/__tests__/cases/5.json b/src/lib/dbml/dbml-export/__tests__/cases/5.json new file mode 100644 index 000000000..89bf8c112 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/5.json @@ -0,0 +1 @@ +{"id":"891455fe2029","name":"fish after update (Copy)","createdAt":"2025-09-17T13:41:55.984Z","updatedAt":"2025-09-17T14:43:51.846Z","databaseType":"postgresql","tables":[{"id":"mow8twxpq4yx9ti2ke1rm3oz9","name":"organizacao_cfg_impressos","schema":"public","order":3,"fields":[{"id":"ub6gos45jgjus7a0ra9xditof","name":"id_organizacao","type":{"name":"integer","id":"integer","usageLevel":1},"nullable":false,"primaryKey":true,"unique":false,"createdAt":1757509919495}],"indexes":[{"id":"5fb2k4hawkhw3pyqvruoi3g6v","name":"pk_organizacao_cfg_impressos_id_organizacao","fieldIds":["ub6gos45jgjus7a0ra9xditof"],"unique":true,"isPrimaryKey":true,"createdAt":1757509919496}],"x":-345,"y":-186,"color":"#ffe374","isView":false,"createdAt":1757022310768,"diagramId":"891455fe2029","parentAreaId":null,"expanded":true},{"id":"zc47ktc9divcdt66ms7vrspod","name":"organizacao","schema":"public","order":1,"fields":[{"id":"uby47oadej9dm37i94je7fqqu","name":"id","type":{"name":"integer","id":"integer","usageLevel":1},"nullable":false,"primaryKey":true,"unique":false,"createdAt":1757022310768}],"indexes":[{"id":"z2a9g6q13mt1y5tk0rskwum7u","name":"pk_organizacao_id","fieldIds":["uby47oadej9dm37i94je7fqqu"],"unique":true,"isPrimaryKey":true,"createdAt":1757114454215},{"id":"ykxz0xuv6w39h5v86rcsd4ba6","name":"idx_tenancy_owner_id","fieldIds":["vpqwjanwu075sgny85ahih51b"],"unique":false,"createdAt":1757023762316}],"x":845,"y":-351,"color":"#ffe374","isView":false,"createdAt":1757022310768,"diagramId":"891455fe2029","parentAreaId":null,"expanded":true}],"relationships":[{"id":"ujdpzbivadyqy1zarb8xaadeo","name":"fk_organizacao_cfg_impressos_organizacao","sourceSchema":"public","targetSchema":"public","sourceTableId":"mow8twxpq4yx9ti2ke1rm3oz9","targetTableId":"zc47ktc9divcdt66ms7vrspod","sourceFieldId":"ub6gos45jgjus7a0ra9xditof","targetFieldId":"uby47oadej9dm37i94je7fqqu","sourceCardinality":"many","targetCardinality":"one","createdAt":1758059720108,"diagramId":"891455fe2029"},{"id":"k40xbk9m8o9tu2ga5y6el9xlv","name":"fk_organizacao_id_organizacao_cfg_impressos_id_organizacao","sourceSchema":"public","targetSchema":"public","sourceTableId":"zc47ktc9divcdt66ms7vrspod","targetTableId":"mow8twxpq4yx9ti2ke1rm3oz9","sourceFieldId":"uby47oadej9dm37i94je7fqqu","targetFieldId":"ub6gos45jgjus7a0ra9xditof","sourceCardinality":"many","targetCardinality":"one","createdAt":1758059720108,"diagramId":"891455fe2029"}],"dependencies":[],"storageMode":"project","lastProjectSavedAt":"2025-09-17T14:43:51.846Z","areas":[],"creationMethod":"imported","customTypes":[{"id":"ppxncapgjqymrcs6sdisxhdom","schema":"","name":"cbhpm_entradas_tipo","kind":"enum","values":["grupo","subgrupo","procedimento"],"order":0,"diagramId":"891455fe2029"},{"id":"l720lbz86bg906xs0d0rjwnwq","schema":"","name":"cid_entradas_tipo","kind":"enum","values":["capitulo","agrupamento","categoria","subcategoria"],"order":0,"diagramId":"891455fe2029"},{"id":"fcyvfh5ilzi1pxg1mazgtr0hb","schema":"","name":"digital_signature_provider","kind":"enum","values":["soluti","valid"],"order":0,"diagramId":"891455fe2029"},{"id":"x0mcajzpi8k1lg5th3o2o7qqh","schema":"","name":"impresso_posicao","kind":"enum","values":["start","center","end"],"order":0,"diagramId":"891455fe2029"},{"id":"hymo5s2uqjl1nkq3kui5ueidc","schema":"","name":"otp_provider","kind":"enum","values":["clinic","soluti_bird_id"],"order":0,"diagramId":"891455fe2029"},{"id":"r02sp4k2vu98x58n648u1hmtu","schema":"","name":"tipo_cobranca","kind":"enum","values":["valor","porte"],"order":0,"diagramId":"891455fe2029"},{"id":"pgyjouza40hqaurziiehcsnde","schema":"","name":"tipo_contato_movel","kind":"enum","values":["celular","telefone_residencial","telefone_comercial"],"order":0,"diagramId":"891455fe2029"},{"id":"ur3khr7o25tzffhzc1s32e7ox","schema":"","name":"tipo_contrato","kind":"enum","values":["trial","common"],"order":0,"diagramId":"891455fe2029"},{"id":"u5jxtrlu22j846e6b4qw9ilrp","schema":"","name":"tipo_endereco","kind":"enum","values":["residencial","comercial","cobranca"],"order":0,"diagramId":"891455fe2029"},{"id":"tat6vdey698u7sop929llxpuv","schema":"","name":"tipo_espectro_autista","kind":"enum","values":["leve","moderado","severo"],"order":0,"diagramId":"891455fe2029"},{"id":"kq7atvktkadu3pha5ur64wf0o","schema":"","name":"tipo_estado_civil","kind":"enum","values":["nao_infomado","solteiro","casado","divorciado","viuvo"],"order":0,"diagramId":"891455fe2029"},{"id":"c9f8bvh6oekcun7aqhmzwg3cx","schema":"","name":"tipo_etnia","kind":"enum","values":["nao_infomado","branca","preta","parda","amarela","indigena"],"order":0,"diagramId":"891455fe2029"},{"id":"u80frjtpqdz31dykzv93mipme","schema":"","name":"tipo_excecao","kind":"enum","values":["bloqueio","compromisso"],"order":0,"diagramId":"891455fe2029"},{"id":"fg46jmrydqjlkm7x968gndewe","schema":"","name":"tipo_metodo_reajuste","kind":"enum","values":["percentual","valor"],"order":0,"diagramId":"891455fe2029"},{"id":"s2q0t3fs7xuo806a4nig9kkue","schema":"","name":"tipo_pessoa","kind":"enum","values":["fisica","juridica"],"order":0,"diagramId":"891455fe2029"},{"id":"561lotk0s99gnddyqp3hj26la","schema":"","name":"tipo_procedimento","kind":"enum","values":["consulta","exame_laboratorial","exame_imagem","procedimento_clinico","procedimento_cirurgico","terapia","outros"],"order":0,"diagramId":"891455fe2029"},{"id":"itq8wzzlej4744z26crl8h0fr","schema":"","name":"tipo_relacionamento","kind":"enum","values":["pai","mae","conjuge","filho_a","tutor_legal","contato_emergencia","outro"],"order":0,"diagramId":"891455fe2029"},{"id":"r80q80je96w85udol4sccb0q0","schema":"","name":"tipo_sexo","kind":"enum","values":["nao_infomado","masculino","feminino","intersexo"],"order":0,"diagramId":"891455fe2029"},{"id":"jveox9qoccr17z6q8pjcyryrl","schema":"","name":"tipo_status_agendamento","kind":"enum","values":["em espera","faltou","ok"],"order":0,"diagramId":"891455fe2029"}]} \ No newline at end of file diff --git a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts index 69bf2bec0..f1fd54ca7 100644 --- a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts +++ b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts @@ -14,14 +14,36 @@ const testCase = (caseNumber: string) => { // Generate DBML from the diagram const result = generateDBMLFromDiagram(diagram); - const generatedDBML = result.standardDbml; - // Read the expected DBML file - const dbmlPath = path.join(__dirname, 'cases', `${caseNumber}.dbml`); - const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); + // Check for both regular and inline DBML files + const regularDbmlPath = path.join(__dirname, 'cases', `${caseNumber}.dbml`); + const inlineDbmlPath = path.join( + __dirname, + 'cases', + `${caseNumber}.inline.dbml` + ); - // Compare the generated DBML with the expected DBML - expect(generatedDBML).toBe(expectedDBML); + const hasRegularDbml = fs.existsSync(regularDbmlPath); + const hasInlineDbml = fs.existsSync(inlineDbmlPath); + + // Test regular DBML if file exists + if (hasRegularDbml) { + const expectedRegularDBML = fs.readFileSync(regularDbmlPath, 'utf-8'); + expect(result.standardDbml).toBe(expectedRegularDBML); + } + + // Test inline DBML if file exists + if (hasInlineDbml) { + const expectedInlineDBML = fs.readFileSync(inlineDbmlPath, 'utf-8'); + expect(result.inlineDbml).toBe(expectedInlineDBML); + } + + // Ensure at least one DBML file exists + if (!hasRegularDbml && !hasInlineDbml) { + throw new Error( + `No DBML file found for test case ${caseNumber}. Expected either ${caseNumber}.dbml or ${caseNumber}.inline.dbml` + ); + } }; describe('DBML Export cases', () => { @@ -40,4 +62,8 @@ describe('DBML Export cases', () => { it('should handle case 4 diagram', { timeout: 30000 }, async () => { testCase('4'); }); + + it('should handle case 5 diagram', { timeout: 30000 }, async () => { + testCase('5'); + }); }); diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index 4ee8d014f..e2d1e1bf4 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -506,15 +506,30 @@ const deduplicateRelationships = (diagram: Diagram): Diagram => { if (!diagram.relationships) return diagram; const seenRelationships = new Set(); + const seenBidirectional = new Set(); const uniqueRelationships = diagram.relationships.filter((rel) => { // Create a unique key based on the relationship endpoints const relationshipKey = `${rel.sourceTableId}-${rel.sourceFieldId}->${rel.targetTableId}-${rel.targetFieldId}`; + // Create a normalized key that's the same for both directions + const normalizedKey = [ + `${rel.sourceTableId}-${rel.sourceFieldId}`, + `${rel.targetTableId}-${rel.targetFieldId}`, + ] + .sort() + .join('<->'); + if (seenRelationships.has(relationshipKey)) { - return false; // Skip duplicate + return false; // Skip exact duplicate + } + + if (seenBidirectional.has(normalizedKey)) { + // This is a bidirectional relationship, skip the second one + return false; } seenRelationships.add(relationshipKey); + seenBidirectional.add(normalizedKey); return true; // Keep unique relationship }); From 93d72a896bab9aa79d8ea2f876126887e432214c Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Wed, 17 Sep 2025 20:23:42 +0300 Subject: [PATCH 014/156] fix: dbml edit mode glitch (#925) --- .../dbml-section/table-dbml/table-dbml.tsx | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/pages/editor-page/side-panel/dbml-section/table-dbml/table-dbml.tsx b/src/pages/editor-page/side-panel/dbml-section/table-dbml/table-dbml.tsx index e2e5e671c..a2602f372 100644 --- a/src/pages/editor-page/side-panel/dbml-section/table-dbml/table-dbml.tsx +++ b/src/pages/editor-page/side-panel/dbml-section/table-dbml/table-dbml.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useRef, } from 'react'; +import { useDebounceFn } from 'ahooks'; import { useChartDB } from '@/hooks/use-chartdb'; import { useTheme } from '@/hooks/use-theme'; import { CodeSnippet } from '@/components/code-snippet/code-snippet'; @@ -25,7 +26,6 @@ import { generateDBMLFromDiagram } from '@/lib/dbml/dbml-export/dbml-export'; import { useDiff } from '@/context/diff-context/use-diff'; import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import'; import { applyDBMLChanges } from '@/lib/dbml/apply-dbml/apply-dbml'; -import { useDebounce } from '@/hooks/use-debounce'; import { parseDBMLError } from '@/lib/dbml/dbml-import/dbml-import-error'; import { clearErrorHighlight, @@ -113,6 +113,17 @@ export const TableDBML: React.FC = () => { const { hideLoader, showLoader } = useFullScreenLoader(); const emphasisTimeoutRef = useRef(); const readOnlyDisposableRef = useRef(); + const currentDiagramRef = useRef(currentDiagram); + const originalDiagramRef = useRef(originalDiagram); + + // Keep refs updated + useEffect(() => { + currentDiagramRef.current = currentDiagram; + }, [currentDiagram]); + + useEffect(() => { + originalDiagramRef.current = originalDiagram; + }, [originalDiagram]); // --- Check for empty field name warnings only on mount --- useEffect(() => { @@ -190,7 +201,7 @@ export const TableDBML: React.FC = () => { ); const sourceDiagram: Diagram = - originalDiagram ?? currentDiagram; + originalDiagramRef.current ?? currentDiagramRef.current; const targetDiagram: Diagram = { ...sourceDiagram, @@ -204,9 +215,9 @@ export const TableDBML: React.FC = () => { targetDiagram, }); - if (originalDiagram) { + if (originalDiagramRef.current) { resetDiff(); - loadDiagramFromData(originalDiagram); + loadDiagramFromData(originalDiagramRef.current); } calculateDiff({ @@ -232,18 +243,12 @@ export const TableDBML: React.FC = () => { } } }, - [ - t, - originalDiagram, - currentDiagram, - resetDiff, - loadDiagramFromData, - calculateDiff, - databaseType, - ] + [t, resetDiff, loadDiagramFromData, calculateDiff, databaseType] ); - const debouncedShowDiff = useDebounce(showDiff, 1000); + const { run: debouncedShowDiff } = useDebounceFn(showDiff, { + wait: 1000, + }); useEffect(() => { if (!isEditMode || !editedDbml) { @@ -359,8 +364,10 @@ export const TableDBML: React.FC = () => { { label: 'View', icon: PencilOff, - onClick: () => - setIsEditMode((prev) => !prev), + onClick: () => { + resetDiff(); + setIsEditMode((prev) => !prev); + }, }, ] : [ From 1b8d51b73c4ed4b7c5929adcb17a44927c7defca Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sat, 20 Sep 2025 19:40:04 +0300 Subject: [PATCH 015/156] fix: diff logic (#927) --- src/lib/domain/diff/area-diff.ts | 70 ++ src/lib/domain/diff/diff-check/diff-check.ts | 745 +++++++++++++------ src/lib/domain/diff/diff.ts | 32 +- src/lib/utils/__tests__/apply-ids.test.ts | 687 +++++++++++++++++ src/lib/utils/apply-ids.ts | 328 ++++++++ 5 files changed, 1638 insertions(+), 224 deletions(-) create mode 100644 src/lib/domain/diff/area-diff.ts create mode 100644 src/lib/utils/__tests__/apply-ids.test.ts create mode 100644 src/lib/utils/apply-ids.ts diff --git a/src/lib/domain/diff/area-diff.ts b/src/lib/domain/diff/area-diff.ts new file mode 100644 index 000000000..b131421fb --- /dev/null +++ b/src/lib/domain/diff/area-diff.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; +import type { Area } from '../area'; + +export type AreaDiffAttribute = keyof Pick; + +const areaDiffAttributeSchema: z.ZodType = z.union([ + z.literal('name'), + z.literal('color'), +]); + +export interface AreaDiffChanged { + object: 'area'; + type: 'changed'; + areaId: string; + attribute: AreaDiffAttribute; + oldValue?: string | null; + newValue?: string | null; +} + +export const AreaDiffChangedSchema: z.ZodType = z.object({ + object: z.literal('area'), + type: z.literal('changed'), + areaId: z.string(), + attribute: areaDiffAttributeSchema, + oldValue: z.string().or(z.null()).optional(), + newValue: z.string().or(z.null()).optional(), +}); + +export interface AreaDiffRemoved { + object: 'area'; + type: 'removed'; + areaId: string; +} + +export const AreaDiffRemovedSchema: z.ZodType = z.object({ + object: z.literal('area'), + type: z.literal('removed'), + areaId: z.string(), +}); + +export interface AreaDiffAdded { + object: 'area'; + type: 'added'; + areaAdded: T; +} + +export const createAreaDiffAddedSchema = ( + areaSchema: z.ZodType +): z.ZodType> => { + return z.object({ + object: z.literal('area'), + type: z.literal('added'), + areaAdded: areaSchema, + }) as z.ZodType>; +}; + +export type AreaDiff = + | AreaDiffChanged + | AreaDiffRemoved + | AreaDiffAdded; + +export const createAreaDiffSchema = ( + areaSchema: z.ZodType +): z.ZodType> => { + return z.union([ + AreaDiffChangedSchema, + AreaDiffRemovedSchema, + createAreaDiffAddedSchema(areaSchema), + ]) as z.ZodType>; +}; diff --git a/src/lib/domain/diff/diff-check/diff-check.ts b/src/lib/domain/diff/diff-check/diff-check.ts index f2d1916d4..dadaa5cd3 100644 --- a/src/lib/domain/diff/diff-check/diff-check.ts +++ b/src/lib/domain/diff/diff-check/diff-check.ts @@ -2,7 +2,14 @@ import type { Diagram } from '@/lib/domain/diagram'; import type { DBField } from '@/lib/domain/db-field'; import type { DBIndex } from '@/lib/domain/db-index'; import type { ChartDBDiff, DiffMap, DiffObject } from '@/lib/domain/diff/diff'; -import type { FieldDiffAttribute } from '@/lib/domain/diff/field-diff'; +import type { + FieldDiff, + FieldDiffAttribute, +} from '@/lib/domain/diff/field-diff'; +import type { TableDiff, TableDiffAttribute } from '../table-diff'; +import type { AreaDiff, AreaDiffAttribute } from '../area-diff'; +import type { IndexDiff } from '../index-diff'; +import type { RelationshipDiff } from '../relationship-diff'; export function getDiffMapKey({ diffObject, @@ -18,23 +25,64 @@ export function getDiffMapKey({ : `${diffObject}-${objectId}`; } +export interface GenerateDiffOptions { + includeTables?: boolean; + includeFields?: boolean; + includeIndexes?: boolean; + includeRelationships?: boolean; + includeAreas?: boolean; + attributes?: { + tables?: TableDiffAttribute[]; + fields?: FieldDiffAttribute[]; + areas?: AreaDiffAttribute[]; + }; + changeTypes?: { + tables?: TableDiff['type'][]; + fields?: FieldDiff['type'][]; + indexes?: IndexDiff['type'][]; + relationships?: RelationshipDiff['type'][]; + areas?: AreaDiff['type'][]; + }; +} + export function generateDiff({ diagram, newDiagram, + options = { + includeTables: true, + includeFields: true, + includeIndexes: true, + includeRelationships: true, + includeAreas: false, + attributes: {}, + changeTypes: {}, + }, }: { diagram: Diagram; newDiagram: Diagram; + options?: GenerateDiffOptions; }): { diffMap: DiffMap; changedTables: Map; changedFields: Map; + changedAreas: Map; } { const newDiffs = new Map(); const changedTables = new Map(); const changedFields = new Map(); + const changedAreas = new Map(); // Compare tables - compareTables({ diagram, newDiagram, diffMap: newDiffs, changedTables }); + if (options.includeTables) { + compareTables({ + diagram, + newDiagram, + diffMap: newDiffs, + changedTables, + attributes: options.attributes?.tables, + changeTypes: options.changeTypes?.tables, + }); + } // Compare fields and indexes for matching tables compareTableContents({ @@ -43,12 +91,32 @@ export function generateDiff({ diffMap: newDiffs, changedTables, changedFields, + options, }); // Compare relationships - compareRelationships({ diagram, newDiagram, diffMap: newDiffs }); + if (options.includeRelationships) { + compareRelationships({ + diagram, + newDiagram, + diffMap: newDiffs, + changeTypes: options.changeTypes?.relationships, + }); + } + + // Compare areas if enabled + if (options.includeAreas) { + compareAreas({ + diagram, + newDiagram, + diffMap: newDiffs, + changedAreas, + attributes: options.attributes?.areas, + changeTypes: options.changeTypes?.areas, + }); + } - return { diffMap: newDiffs, changedTables, changedFields }; + return { diffMap: newDiffs, changedTables, changedFields, changedAreas }; } // Compare tables between diagrams @@ -57,112 +125,150 @@ function compareTables({ newDiagram, diffMap, changedTables, + attributes, + changeTypes, }: { diagram: Diagram; newDiagram: Diagram; diffMap: DiffMap; changedTables: Map; + attributes?: TableDiffAttribute[]; + changeTypes?: TableDiff['type'][]; }) { const oldTables = diagram.tables || []; const newTables = newDiagram.tables || []; + // If changeTypes is empty array, don't check any changes + if (changeTypes && changeTypes.length === 0) { + return; + } + + // If changeTypes is undefined, check all types + const typesToCheck = changeTypes ?? ['added', 'removed', 'changed']; + // Check for added tables - for (const newTable of newTables) { - if (!oldTables.find((t) => t.id === newTable.id)) { - diffMap.set( - getDiffMapKey({ diffObject: 'table', objectId: newTable.id }), - { - object: 'table', - type: 'added', - tableAdded: newTable, - } - ); - changedTables.set(newTable.id, true); + if (typesToCheck.includes('added')) { + for (const newTable of newTables) { + if (!oldTables.find((t) => t.id === newTable.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'table', + objectId: newTable.id, + }), + { + object: 'table', + type: 'added', + tableAdded: newTable, + } + ); + changedTables.set(newTable.id, true); + } } } // Check for removed tables - for (const oldTable of oldTables) { - if (!newTables.find((t) => t.id === oldTable.id)) { - diffMap.set( - getDiffMapKey({ diffObject: 'table', objectId: oldTable.id }), - { - object: 'table', - type: 'removed', - tableId: oldTable.id, - } - ); - changedTables.set(oldTable.id, true); + if (typesToCheck.includes('removed')) { + for (const oldTable of oldTables) { + if (!newTables.find((t) => t.id === oldTable.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'table', + objectId: oldTable.id, + }), + { + object: 'table', + type: 'removed', + tableId: oldTable.id, + } + ); + changedTables.set(oldTable.id, true); + } } } // Check for table name, comments and color changes - for (const oldTable of oldTables) { - const newTable = newTables.find((t) => t.id === oldTable.id); - - if (!newTable) continue; - - if (oldTable.name !== newTable.name) { - diffMap.set( - getDiffMapKey({ - diffObject: 'table', - objectId: oldTable.id, - attribute: 'name', - }), - { - object: 'table', - type: 'changed', - tableId: oldTable.id, - attribute: 'name', - newValue: newTable.name, - oldValue: oldTable.name, - } - ); - - changedTables.set(oldTable.id, true); - } - - if ( - (oldTable.comments || newTable.comments) && - oldTable.comments !== newTable.comments - ) { - diffMap.set( - getDiffMapKey({ - diffObject: 'table', - objectId: oldTable.id, - attribute: 'comments', - }), - { - object: 'table', - type: 'changed', - tableId: oldTable.id, - attribute: 'comments', - newValue: newTable.comments, - oldValue: oldTable.comments, - } - ); - - changedTables.set(oldTable.id, true); - } - - if (oldTable.color !== newTable.color) { - diffMap.set( - getDiffMapKey({ - diffObject: 'table', - objectId: oldTable.id, - attribute: 'color', - }), - { - object: 'table', - type: 'changed', - tableId: oldTable.id, - attribute: 'color', - newValue: newTable.color, - oldValue: oldTable.color, - } - ); - - changedTables.set(oldTable.id, true); + if (typesToCheck.includes('changed')) { + for (const oldTable of oldTables) { + const newTable = newTables.find((t) => t.id === oldTable.id); + + if (!newTable) continue; + + // If attributes are specified, only check those attributes + const attributesToCheck: TableDiffAttribute[] = attributes ?? [ + 'name', + 'comments', + 'color', + ]; + + if ( + attributesToCheck.includes('name') && + oldTable.name !== newTable.name + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'table', + objectId: oldTable.id, + attribute: 'name', + }), + { + object: 'table', + type: 'changed', + tableId: oldTable.id, + attribute: 'name', + newValue: newTable.name, + oldValue: oldTable.name, + } + ); + + changedTables.set(oldTable.id, true); + } + + if ( + attributesToCheck.includes('comments') && + (oldTable.comments || newTable.comments) && + oldTable.comments !== newTable.comments + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'table', + objectId: oldTable.id, + attribute: 'comments', + }), + { + object: 'table', + type: 'changed', + tableId: oldTable.id, + attribute: 'comments', + newValue: newTable.comments, + oldValue: oldTable.comments, + } + ); + + changedTables.set(oldTable.id, true); + } + + if ( + attributesToCheck.includes('color') && + oldTable.color !== newTable.color + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'table', + objectId: oldTable.id, + attribute: 'color', + }), + { + object: 'table', + type: 'changed', + tableId: oldTable.id, + attribute: 'color', + newValue: newTable.color, + oldValue: oldTable.color, + } + ); + + changedTables.set(oldTable.id, true); + } } } } @@ -174,12 +280,14 @@ function compareTableContents({ diffMap, changedTables, changedFields, + options, }: { diagram: Diagram; newDiagram: Diagram; diffMap: DiffMap; changedTables: Map; changedFields: Map; + options?: GenerateDiffOptions; }) { const oldTables = diagram.tables || []; const newTables = newDiagram.tables || []; @@ -190,23 +298,30 @@ function compareTableContents({ if (!newTable) continue; // Compare fields - compareFields({ - tableId: oldTable.id, - oldFields: oldTable.fields, - newFields: newTable.fields, - diffMap, - changedTables, - changedFields, - }); + if (options?.includeFields) { + compareFields({ + tableId: oldTable.id, + oldFields: oldTable.fields, + newFields: newTable.fields, + diffMap, + changedTables, + changedFields, + attributes: options?.attributes?.fields, + changeTypes: options?.changeTypes?.fields, + }); + } // Compare indexes - compareIndexes({ - tableId: oldTable.id, - oldIndexes: oldTable.indexes, - newIndexes: newTable.indexes, - diffMap, - changedTables, - }); + if (options?.includeIndexes) { + compareIndexes({ + tableId: oldTable.id, + oldIndexes: oldTable.indexes, + newIndexes: newTable.indexes, + diffMap, + changedTables, + changeTypes: options?.changeTypes?.indexes, + }); + } } } @@ -218,6 +333,8 @@ function compareFields({ diffMap, changedTables, changedFields, + attributes, + changeTypes, }: { tableId: string; oldFields: DBField[]; @@ -225,62 +342,78 @@ function compareFields({ diffMap: DiffMap; changedTables: Map; changedFields: Map; + attributes?: FieldDiffAttribute[]; + changeTypes?: FieldDiff['type'][]; }) { + // If changeTypes is empty array, don't check any changes + if (changeTypes && changeTypes.length === 0) { + return; + } + + // If changeTypes is undefined, check all types + const typesToCheck = changeTypes ?? ['added', 'removed', 'changed']; // Check for added fields - for (const newField of newFields) { - if (!oldFields.find((f) => f.id === newField.id)) { - diffMap.set( - getDiffMapKey({ - diffObject: 'field', - objectId: newField.id, - }), - { - object: 'field', - type: 'added', - newField, - tableId, - } - ); - changedTables.set(tableId, true); - changedFields.set(newField.id, true); + if (typesToCheck.includes('added')) { + for (const newField of newFields) { + if (!oldFields.find((f) => f.id === newField.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'field', + objectId: newField.id, + }), + { + object: 'field', + type: 'added', + newField, + tableId, + } + ); + changedTables.set(tableId, true); + changedFields.set(newField.id, true); + } } } // Check for removed fields - for (const oldField of oldFields) { - if (!newFields.find((f) => f.id === oldField.id)) { - diffMap.set( - getDiffMapKey({ - diffObject: 'field', - objectId: oldField.id, - }), - { - object: 'field', - type: 'removed', - fieldId: oldField.id, - tableId, - } - ); - - changedTables.set(tableId, true); - changedFields.set(oldField.id, true); + if (typesToCheck.includes('removed')) { + for (const oldField of oldFields) { + if (!newFields.find((f) => f.id === oldField.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'field', + objectId: oldField.id, + }), + { + object: 'field', + type: 'removed', + fieldId: oldField.id, + tableId, + } + ); + + changedTables.set(tableId, true); + changedFields.set(oldField.id, true); + } } } // Check for field changes - for (const oldField of oldFields) { - const newField = newFields.find((f) => f.id === oldField.id); - if (!newField) continue; - - // Compare basic field properties - compareFieldProperties({ - tableId, - oldField, - newField, - diffMap, - changedTables, - changedFields, - }); + if (typesToCheck.includes('changed')) { + for (const oldField of oldFields) { + const newField = newFields.find((f) => f.id === oldField.id); + if (!newField) continue; + + // Compare basic field properties + compareFieldProperties({ + tableId, + oldField, + newField, + diffMap, + changedTables, + changedFields, + attributes, + }); + } } } @@ -292,6 +425,7 @@ function compareFieldProperties({ diffMap, changedTables, changedFields, + attributes, }: { tableId: string; oldField: DBField; @@ -299,30 +433,57 @@ function compareFieldProperties({ diffMap: DiffMap; changedTables: Map; changedFields: Map; + attributes?: FieldDiffAttribute[]; }) { + // If attributes are specified, only check those attributes + const attributesToCheck: FieldDiffAttribute[] = attributes ?? [ + 'name', + 'type', + 'primaryKey', + 'unique', + 'nullable', + 'comments', + 'characterMaximumLength', + 'scale', + 'precision', + ]; + const changedAttributes: FieldDiffAttribute[] = []; - if (oldField.name !== newField.name) { + if (attributesToCheck.includes('name') && oldField.name !== newField.name) { changedAttributes.push('name'); } - if (oldField.type.id !== newField.type.id) { + if ( + attributesToCheck.includes('type') && + oldField.type.id !== newField.type.id + ) { changedAttributes.push('type'); } - if (oldField.primaryKey !== newField.primaryKey) { + if ( + attributesToCheck.includes('primaryKey') && + oldField.primaryKey !== newField.primaryKey + ) { changedAttributes.push('primaryKey'); } - if (oldField.unique !== newField.unique) { + if ( + attributesToCheck.includes('unique') && + oldField.unique !== newField.unique + ) { changedAttributes.push('unique'); } - if (oldField.nullable !== newField.nullable) { + if ( + attributesToCheck.includes('nullable') && + oldField.nullable !== newField.nullable + ) { changedAttributes.push('nullable'); } if ( + attributesToCheck.includes('comments') && (newField.comments || oldField.comments) && oldField.comments !== newField.comments ) { @@ -330,6 +491,7 @@ function compareFieldProperties({ } if ( + attributesToCheck.includes('characterMaximumLength') && (newField.characterMaximumLength || oldField.characterMaximumLength) && oldField.characterMaximumLength !== newField.characterMaximumLength ) { @@ -337,6 +499,7 @@ function compareFieldProperties({ } if ( + attributesToCheck.includes('scale') && (newField.scale || oldField.scale) && oldField.scale !== newField.scale ) { @@ -344,6 +507,7 @@ function compareFieldProperties({ } if ( + attributesToCheck.includes('precision') && (newField.precision || oldField.precision) && oldField.precision !== newField.precision ) { @@ -381,48 +545,61 @@ function compareIndexes({ newIndexes, diffMap, changedTables, + changeTypes, }: { tableId: string; oldIndexes: DBIndex[]; newIndexes: DBIndex[]; diffMap: DiffMap; changedTables: Map; + changeTypes?: IndexDiff['type'][]; }) { + // If changeTypes is empty array, don't check any changes + if (changeTypes && changeTypes.length === 0) { + return; + } + + // If changeTypes is undefined, check all types + const typesToCheck = changeTypes ?? ['added', 'removed']; // Check for added indexes - for (const newIndex of newIndexes) { - if (!oldIndexes.find((i) => i.id === newIndex.id)) { - diffMap.set( - getDiffMapKey({ - diffObject: 'index', - objectId: newIndex.id, - }), - { - object: 'index', - type: 'added', - newIndex, - tableId, - } - ); - changedTables.set(tableId, true); + if (typesToCheck.includes('added')) { + for (const newIndex of newIndexes) { + if (!oldIndexes.find((i) => i.id === newIndex.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'index', + objectId: newIndex.id, + }), + { + object: 'index', + type: 'added', + newIndex, + tableId, + } + ); + changedTables.set(tableId, true); + } } } // Check for removed indexes - for (const oldIndex of oldIndexes) { - if (!newIndexes.find((i) => i.id === oldIndex.id)) { - diffMap.set( - getDiffMapKey({ - diffObject: 'index', - objectId: oldIndex.id, - }), - { - object: 'index', - type: 'removed', - indexId: oldIndex.id, - tableId, - } - ); - changedTables.set(tableId, true); + if (typesToCheck.includes('removed')) { + for (const oldIndex of oldIndexes) { + if (!newIndexes.find((i) => i.id === oldIndex.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'index', + objectId: oldIndex.id, + }), + { + object: 'index', + type: 'removed', + indexId: oldIndex.id, + tableId, + } + ); + changedTables.set(tableId, true); + } } } } @@ -432,45 +609,185 @@ function compareRelationships({ diagram, newDiagram, diffMap, + changeTypes, }: { diagram: Diagram; newDiagram: Diagram; diffMap: DiffMap; + changeTypes?: RelationshipDiff['type'][]; }) { + // If changeTypes is empty array, don't check any changes + if (changeTypes && changeTypes.length === 0) { + return; + } + + // If changeTypes is undefined, check all types + const typesToCheck = changeTypes ?? ['added', 'removed']; const oldRelationships = diagram.relationships || []; const newRelationships = newDiagram.relationships || []; // Check for added relationships - for (const newRelationship of newRelationships) { - if (!oldRelationships.find((r) => r.id === newRelationship.id)) { - diffMap.set( - getDiffMapKey({ - diffObject: 'relationship', - objectId: newRelationship.id, - }), - { - object: 'relationship', - type: 'added', - newRelationship, - } - ); + if (typesToCheck.includes('added')) { + for (const newRelationship of newRelationships) { + if (!oldRelationships.find((r) => r.id === newRelationship.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'relationship', + objectId: newRelationship.id, + }), + { + object: 'relationship', + type: 'added', + newRelationship, + } + ); + } } } // Check for removed relationships - for (const oldRelationship of oldRelationships) { - if (!newRelationships.find((r) => r.id === oldRelationship.id)) { - diffMap.set( - getDiffMapKey({ - diffObject: 'relationship', - objectId: oldRelationship.id, - }), - { - object: 'relationship', - type: 'removed', - relationshipId: oldRelationship.id, - } - ); + if (typesToCheck.includes('removed')) { + for (const oldRelationship of oldRelationships) { + if (!newRelationships.find((r) => r.id === oldRelationship.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'relationship', + objectId: oldRelationship.id, + }), + { + object: 'relationship', + type: 'removed', + relationshipId: oldRelationship.id, + } + ); + } + } + } +} + +// Compare areas between diagrams +function compareAreas({ + diagram, + newDiagram, + diffMap, + changedAreas, + attributes, + changeTypes, +}: { + diagram: Diagram; + newDiagram: Diagram; + diffMap: DiffMap; + changedAreas: Map; + attributes?: AreaDiffAttribute[]; + changeTypes?: AreaDiff['type'][]; +}) { + const oldAreas = diagram.areas || []; + const newAreas = newDiagram.areas || []; + + // If changeTypes is empty array, don't check any changes + if (changeTypes && changeTypes.length === 0) { + return; + } + + // If changeTypes is undefined, check all types + const typesToCheck = changeTypes ?? ['added', 'removed', 'changed']; + + // Check for added areas + if (typesToCheck.includes('added')) { + for (const newArea of newAreas) { + if (!oldAreas.find((a) => a.id === newArea.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'area', + objectId: newArea.id, + }), + { + object: 'area', + type: 'added', + areaAdded: newArea, + } + ); + changedAreas.set(newArea.id, true); + } + } + } + + // Check for removed areas + if (typesToCheck.includes('removed')) { + for (const oldArea of oldAreas) { + if (!newAreas.find((a) => a.id === oldArea.id)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'area', + objectId: oldArea.id, + }), + { + object: 'area', + type: 'removed', + areaId: oldArea.id, + } + ); + changedAreas.set(oldArea.id, true); + } + } + } + + // Check for area name and color changes + if (typesToCheck.includes('changed')) { + for (const oldArea of oldAreas) { + const newArea = newAreas.find((a) => a.id === oldArea.id); + + if (!newArea) continue; + + // If attributes are specified, only check those attributes + const attributesToCheck: AreaDiffAttribute[] = attributes ?? [ + 'name', + 'color', + ]; + + if ( + attributesToCheck.includes('name') && + oldArea.name !== newArea.name + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'area', + objectId: oldArea.id, + attribute: 'name', + }), + { + object: 'area', + type: 'changed', + areaId: oldArea.id, + attribute: 'name', + newValue: newArea.name, + oldValue: oldArea.name, + } + ); + changedAreas.set(oldArea.id, true); + } + + if ( + attributesToCheck.includes('color') && + oldArea.color !== newArea.color + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'area', + objectId: oldArea.id, + attribute: 'color', + }), + { + object: 'area', + type: 'changed', + areaId: oldArea.id, + attribute: 'color', + newValue: newArea.color, + oldValue: oldArea.color, + } + ); + changedAreas.set(oldArea.id, true); + } } } } diff --git a/src/lib/domain/diff/diff.ts b/src/lib/domain/diff/diff.ts index 6129f523e..524214cde 100644 --- a/src/lib/domain/diff/diff.ts +++ b/src/lib/domain/diff/diff.ts @@ -8,36 +8,43 @@ import type { RelationshipDiff } from './relationship-diff'; import { createRelationshipDiffSchema } from './relationship-diff'; import type { TableDiff } from './table-diff'; import { createTableDiffSchema } from './table-diff'; -import type { DBField, DBIndex, DBRelationship, DBTable } from '..'; +import type { AreaDiff } from './area-diff'; +import { createAreaDiffSchema } from './area-diff'; +import type { DBField, DBIndex, DBRelationship, DBTable, Area } from '..'; export type ChartDBDiff< TTable = DBTable, TField = DBField, TIndex = DBIndex, TRelationship = DBRelationship, + TArea = Area, > = | TableDiff | FieldDiff | IndexDiff - | RelationshipDiff; + | RelationshipDiff + | AreaDiff; export const createChartDBDiffSchema = < TTable = DBTable, TField = DBField, TIndex = DBIndex, TRelationship = DBRelationship, + TArea = Area, >( tableSchema: z.ZodType, fieldSchema: z.ZodType, indexSchema: z.ZodType, - relationshipSchema: z.ZodType -): z.ZodType> => { + relationshipSchema: z.ZodType, + areaSchema: z.ZodType +): z.ZodType> => { return z.union([ createTableDiffSchema(tableSchema), createFieldDiffSchema(fieldSchema), createIndexDiffSchema(indexSchema), createRelationshipDiffSchema(relationshipSchema), - ]) as z.ZodType>; + createAreaDiffSchema(areaSchema), + ]) as z.ZodType>; }; export type DiffMap< @@ -45,18 +52,21 @@ export type DiffMap< TField = DBField, TIndex = DBIndex, TRelationship = DBRelationship, -> = Map>; + TArea = Area, +> = Map>; export type DiffObject< TTable = DBTable, TField = DBField, TIndex = DBIndex, TRelationship = DBRelationship, + TArea = Area, > = | TableDiff['object'] | FieldDiff['object'] | IndexDiff['object'] - | RelationshipDiff['object']; + | RelationshipDiff['object'] + | AreaDiff['object']; type ExtractDiffKind = T extends { object: infer O; type: infer Type } ? T extends { attribute: infer A } @@ -69,16 +79,18 @@ export type DiffKind< TField = DBField, TIndex = DBIndex, TRelationship = DBRelationship, -> = ExtractDiffKind>; + TArea = Area, +> = ExtractDiffKind>; export const isDiffOfKind = < TTable = DBTable, TField = DBField, TIndex = DBIndex, TRelationship = DBRelationship, + TArea = Area, >( - diff: ChartDBDiff, - kind: DiffKind + diff: ChartDBDiff, + kind: DiffKind ): boolean => { if ('attribute' in kind) { return ( diff --git a/src/lib/utils/__tests__/apply-ids.test.ts b/src/lib/utils/__tests__/apply-ids.test.ts new file mode 100644 index 000000000..9a4f0ac82 --- /dev/null +++ b/src/lib/utils/__tests__/apply-ids.test.ts @@ -0,0 +1,687 @@ +import { describe, expect, it } from 'vitest'; +import { applyIds } from '../apply-ids'; +import { + DatabaseType, + DBCustomTypeKind, + type Diagram, + type DBTable, + type DBField, + type DBIndex, + type DBRelationship, + type DBDependency, + type DBCustomType, +} from '../../domain'; + +describe('applyIds', () => { + const createBaseDiagram = (overrides?: Partial): Diagram => ({ + id: 'diagram1', + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }); + + const createTable = (overrides: Partial): DBTable => ({ + id: 'table-1', + name: 'table', + schema: 'public', + x: 0, + y: 0, + fields: [], + indexes: [], + color: '#000000', + comments: null, + isView: false, + createdAt: Date.now(), + ...overrides, + }); + + const createField = (overrides: Partial): DBField => ({ + id: 'field-1', + name: 'field', + type: { id: 'integer', name: 'integer' }, + primaryKey: false, + nullable: false, + unique: false, + comments: null, + collation: null, + createdAt: Date.now(), + ...overrides, + }); + + const createIndex = (overrides: Partial): DBIndex => ({ + id: 'index-1', + name: 'index', + unique: false, + fieldIds: [], + createdAt: Date.now(), + ...overrides, + }); + + const createRelationship = ( + overrides: Partial + ): DBRelationship => ({ + id: 'rel-1', + name: 'relationship', + sourceTableId: 'table-1', + sourceFieldId: 'field-1', + targetTableId: 'table-2', + targetFieldId: 'field-2', + sourceCardinality: 'many', + targetCardinality: 'one', + createdAt: Date.now(), + ...overrides, + }); + + const createDependency = ( + overrides: Partial + ): DBDependency => ({ + id: 'dep-1', + tableId: 'table-1', + dependentTableId: 'table-2', + createdAt: Date.now(), + ...overrides, + }); + + const createCustomType = ( + overrides: Partial + ): DBCustomType => ({ + id: 'type-1', + name: 'custom_type', + schema: 'public', + kind: DBCustomTypeKind.enum, + values: [], + ...overrides, + }); + + describe('table ID mapping', () => { + it('should preserve table IDs when tables match by name and schema', () => { + const sourceDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'source-table-1', + name: 'users', + schema: 'public', + }), + createTable({ + id: 'source-table-2', + name: 'posts', + schema: 'public', + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'target-table-1', + name: 'users', + schema: 'public', + x: 100, + y: 100, + color: '#ff0000', + comments: 'Users table', + }), + createTable({ + id: 'target-table-2', + name: 'posts', + schema: 'public', + x: 200, + y: 200, + color: '#00ff00', + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result.tables).toHaveLength(2); + expect(result.tables?.[0].id).toBe('source-table-1'); + expect(result.tables?.[0].name).toBe('users'); + expect(result.tables?.[0].x).toBe(100); // Should keep target's position + expect(result.tables?.[0].color).toBe('#ff0000'); // Should keep target's color + expect(result.tables?.[1].id).toBe('source-table-2'); + expect(result.tables?.[1].name).toBe('posts'); + }); + + it('should keep target table IDs when no matching source table exists', () => { + const sourceDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'source-table-1', + name: 'users', + schema: 'public', + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'target-table-1', + name: 'orders', + schema: 'public', + x: 100, + y: 100, + color: '#ff0000', + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result.tables).toHaveLength(1); + expect(result.tables?.[0].id).toBe('target-table-1'); // Should keep target ID + expect(result.tables?.[0].name).toBe('orders'); + }); + }); + + describe('field ID mapping', () => { + it('should preserve field IDs when fields match by name within the same table', () => { + const sourceDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'source-table-1', + name: 'users', + schema: 'public', + fields: [ + createField({ + id: 'source-field-1', + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + nullable: false, + unique: true, + }), + createField({ + id: 'source-field-2', + name: 'email', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + nullable: false, + unique: true, + }), + ], + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'target-table-1', + name: 'users', + schema: 'public', + x: 100, + y: 100, + color: '#ff0000', + fields: [ + createField({ + id: 'target-field-1', + name: 'id', + type: { id: 'bigint', name: 'bigint' }, + primaryKey: true, + nullable: false, + unique: true, + comments: 'Primary key', + }), + createField({ + id: 'target-field-2', + name: 'email', + type: { id: 'text', name: 'text' }, + primaryKey: false, + nullable: true, + unique: false, + }), + ], + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result.tables?.[0].fields).toHaveLength(2); + expect(result.tables?.[0].fields[0].id).toBe('source-field-1'); + expect(result.tables?.[0].fields[0].name).toBe('id'); + expect(result.tables?.[0].fields[0].type.id).toBe('bigint'); // Should keep target's type + expect(result.tables?.[0].fields[1].id).toBe('source-field-2'); + expect(result.tables?.[0].fields[1].name).toBe('email'); + expect(result.tables?.[0].fields[1].nullable).toBe(true); // Should keep target's nullable + }); + }); + + describe('index ID mapping', () => { + it('should preserve index IDs and update field references', () => { + const sourceDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'source-table-1', + name: 'users', + schema: 'public', + fields: [ + createField({ + id: 'source-field-1', + name: 'email', + type: { id: 'varchar', name: 'varchar' }, + }), + ], + indexes: [ + createIndex({ + id: 'source-index-1', + name: 'idx_email', + unique: true, + fieldIds: ['source-field-1'], + }), + ], + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'target-table-1', + name: 'users', + schema: 'public', + x: 100, + y: 100, + color: '#ff0000', + fields: [ + createField({ + id: 'target-field-1', + name: 'email', + type: { id: 'text', name: 'text' }, + }), + ], + indexes: [ + createIndex({ + id: 'target-index-1', + name: 'idx_email', + unique: false, + fieldIds: ['target-field-1'], + }), + ], + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result.tables?.[0].indexes).toHaveLength(1); + expect(result.tables?.[0].indexes[0].id).toBe('source-index-1'); + expect(result.tables?.[0].indexes[0].fieldIds).toEqual([ + 'source-field-1', + ]); // Should update field reference + expect(result.tables?.[0].indexes[0].unique).toBe(false); // Should keep target's unique setting + }); + }); + + describe('relationship ID mapping', () => { + it('should preserve relationship IDs and update table/field references', () => { + const sourceDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'source-table-1', + name: 'users', + schema: 'public', + fields: [ + createField({ + id: 'source-field-1', + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + }), + ], + }), + createTable({ + id: 'source-table-2', + name: 'posts', + schema: 'public', + fields: [ + createField({ + id: 'source-field-2', + name: 'user_id', + type: { id: 'integer', name: 'integer' }, + }), + ], + }), + ], + relationships: [ + createRelationship({ + id: 'source-rel-1', + name: 'fk_posts_users', + sourceTableId: 'source-table-2', + sourceFieldId: 'source-field-2', + targetTableId: 'source-table-1', + targetFieldId: 'source-field-1', + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'target-table-1', + name: 'users', + schema: 'public', + x: 100, + y: 100, + color: '#ff0000', + fields: [ + createField({ + id: 'target-field-1', + name: 'id', + type: { id: 'bigint', name: 'bigint' }, + primaryKey: true, + unique: true, + }), + ], + }), + createTable({ + id: 'target-table-2', + name: 'posts', + schema: 'public', + x: 200, + y: 200, + color: '#00ff00', + fields: [ + createField({ + id: 'target-field-2', + name: 'user_id', + type: { id: 'bigint', name: 'bigint' }, + nullable: true, + }), + ], + }), + ], + relationships: [ + createRelationship({ + id: 'target-rel-1', + name: 'fk_posts_users', + sourceTableId: 'target-table-2', + sourceFieldId: 'target-field-2', + targetTableId: 'target-table-1', + targetFieldId: 'target-field-1', + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result.relationships).toHaveLength(1); + expect(result.relationships?.[0].id).toBe('source-rel-1'); + expect(result.relationships?.[0].sourceTableId).toBe( + 'source-table-2' + ); + expect(result.relationships?.[0].sourceFieldId).toBe( + 'source-field-2' + ); + expect(result.relationships?.[0].targetTableId).toBe( + 'source-table-1' + ); + expect(result.relationships?.[0].targetFieldId).toBe( + 'source-field-1' + ); + }); + }); + + describe('dependency ID mapping', () => { + it('should preserve dependency IDs and update table references', () => { + const sourceDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'source-table-1', + name: 'users', + schema: 'public', + }), + createTable({ + id: 'source-table-2', + name: 'user_view', + schema: 'public', + isView: true, + }), + ], + dependencies: [ + createDependency({ + id: 'source-dep-1', + tableId: 'source-table-2', + dependentTableId: 'source-table-1', + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'target-table-1', + name: 'users', + schema: 'public', + x: 100, + y: 100, + color: '#ff0000', + }), + createTable({ + id: 'target-table-2', + name: 'user_view', + schema: 'public', + x: 200, + y: 200, + color: '#00ff00', + isView: true, + }), + ], + dependencies: [ + createDependency({ + id: 'target-dep-1', + tableId: 'target-table-2', + dependentTableId: 'target-table-1', + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result.dependencies).toHaveLength(1); + expect(result.dependencies?.[0].id).toBe('source-dep-1'); + expect(result.dependencies?.[0].tableId).toBe('source-table-2'); + expect(result.dependencies?.[0].dependentTableId).toBe( + 'source-table-1' + ); + }); + }); + + describe('custom type ID mapping', () => { + it('should preserve custom type IDs when types match by name and schema', () => { + const sourceDiagram = createBaseDiagram({ + customTypes: [ + createCustomType({ + id: 'source-type-1', + name: 'user_role', + schema: 'public', + values: ['admin', 'user', 'guest'], + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + customTypes: [ + createCustomType({ + id: 'target-type-1', + name: 'user_role', + schema: 'public', + values: ['admin', 'user', 'guest', 'moderator'], + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result.customTypes).toHaveLength(1); + expect(result.customTypes?.[0].id).toBe('source-type-1'); + expect(result.customTypes?.[0].values).toEqual([ + 'admin', + 'user', + 'guest', + 'moderator', + ]); // Should keep target's values + }); + }); + + describe('complex scenarios', () => { + it('should handle partial matches correctly', () => { + const sourceDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'source-table-1', + name: 'users', + schema: 'public', + fields: [ + createField({ + id: 'source-field-1', + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + }), + createField({ + id: 'source-field-2', + name: 'email', + type: { id: 'varchar', name: 'varchar' }, + unique: true, + }), + ], + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'target-table-1', + name: 'users', + schema: 'public', + x: 100, + y: 100, + color: '#ff0000', + fields: [ + createField({ + id: 'target-field-1', + name: 'id', + type: { id: 'bigint', name: 'bigint' }, + primaryKey: true, + unique: true, + }), + createField({ + id: 'target-field-3', + name: 'username', + type: { id: 'varchar', name: 'varchar' }, + unique: true, + }), + ], + }), + createTable({ + id: 'target-table-2', + name: 'posts', + schema: 'public', + x: 200, + y: 200, + color: '#00ff00', + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result.tables).toHaveLength(2); + expect(result.tables?.[0].id).toBe('source-table-1'); + expect(result.tables?.[0].fields).toHaveLength(2); + expect(result.tables?.[0].fields[0].id).toBe('source-field-1'); // Matched field + expect(result.tables?.[0].fields[1].id).toBe('target-field-3'); // Unmatched field keeps target ID + expect(result.tables?.[1].id).toBe('target-table-2'); // Unmatched table keeps target ID + }); + + it('should handle different schemas correctly', () => { + const sourceDiagram = createBaseDiagram({ + databaseType: DatabaseType.POSTGRESQL, + tables: [ + createTable({ + id: 'source-table-1', + name: 'users', + schema: 'auth', + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + databaseType: DatabaseType.POSTGRESQL, + tables: [ + createTable({ + id: 'target-table-1', + name: 'users', + schema: 'public', + x: 100, + y: 100, + color: '#ff0000', + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result.tables?.[0].id).toBe('target-table-1'); // Different schemas, no match + }); + + it('should handle empty diagrams', () => { + const sourceDiagram = createBaseDiagram(); + const targetDiagram = createBaseDiagram(); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + expect(result).toEqual(targetDiagram); + }); + + it('should return target diagram unchanged when source has no matching entities', () => { + const sourceDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'source-table-1', + name: 'products', + schema: 'inventory', + }), + ], + }); + + const targetDiagram = createBaseDiagram({ + tables: [ + createTable({ + id: 'target-table-1', + name: 'users', + schema: 'public', + x: 100, + y: 100, + color: '#ff0000', + fields: [ + createField({ + id: 'target-field-1', + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + }), + ], + }), + ], + }); + + const result = applyIds({ sourceDiagram, targetDiagram }); + + // Should keep all target IDs since nothing matches + expect(result.tables?.[0].id).toBe('target-table-1'); + expect(result.tables?.[0].fields[0].id).toBe('target-field-1'); + expect(result.tables?.[0].name).toBe('users'); + expect(result.tables?.[0].schema).toBe('public'); + }); + }); +}); diff --git a/src/lib/utils/apply-ids.ts b/src/lib/utils/apply-ids.ts new file mode 100644 index 000000000..72d37d7b1 --- /dev/null +++ b/src/lib/utils/apply-ids.ts @@ -0,0 +1,328 @@ +import { defaultSchemas } from '../data/default-schemas'; +import type { DBCustomType, DBTable, Diagram } from '../domain'; + +const createTableKey = ({ + table, + defaultSchema, +}: { + table: DBTable; + defaultSchema?: string; +}) => { + return `${table.schema ?? defaultSchema ?? ''}::${table.name}`; +}; + +const createFieldKey = ({ + table, + fieldName, + defaultSchema, +}: { + table: DBTable; + fieldName: string; + defaultSchema?: string; +}) => { + return `${table.schema ?? defaultSchema ?? ''}::${table.name}::${fieldName}`; +}; + +const createIndexKey = ({ + table, + indexName, + defaultSchema, +}: { + table: DBTable; + indexName: string; + defaultSchema?: string; +}) => { + return `${table.schema ?? defaultSchema ?? ''}::${table.name}::${indexName}`; +}; + +const createRelationshipKey = ({ + relationshipName, + defaultSchema, +}: { + relationshipName: string; + defaultSchema?: string; +}) => { + return `${defaultSchema ?? ''}::${relationshipName}`; +}; + +const createDependencyKey = ({ + table, + dependentTable, + defaultSchema, +}: { + table: DBTable; + dependentTable: DBTable; + defaultSchema?: string; +}) => { + return `${table.schema ?? defaultSchema ?? ''}::${table.name}::${dependentTable.schema ?? defaultSchema ?? ''}::${dependentTable.name}`; +}; + +const createCustomTypeKey = ({ + customType, + defaultSchema, +}: { + customType: DBCustomType; + defaultSchema?: string; +}) => { + return `${customType.schema ?? defaultSchema ?? ''}::${customType.name}`; +}; + +export const applyIds = ({ + sourceDiagram, + targetDiagram, +}: { + sourceDiagram: Diagram; + targetDiagram: Diagram; +}): Diagram => { + // Create a mapping of old IDs to new IDs + const tablesIdMapping = new Map(); + const fieldsIdMapping = new Map(); + const indexesIdMapping = new Map(); + const relationshipsIdMapping = new Map(); + const dependenciesIdMapping = new Map(); + const customTypesIdMapping = new Map(); + + const sourceDefaultSchema = defaultSchemas[sourceDiagram.databaseType]; + const targetDefaultSchema = defaultSchemas[targetDiagram.databaseType]; + + // build idMapping + sourceDiagram?.tables?.forEach((sourceTable) => { + const sourceKey = createTableKey({ + table: sourceTable, + defaultSchema: sourceDefaultSchema, + }); + tablesIdMapping.set(sourceKey, sourceTable.id); + + sourceTable.fields.forEach((field) => { + const fieldKey = createFieldKey({ + table: sourceTable, + fieldName: field.name, + defaultSchema: sourceDefaultSchema, + }); + fieldsIdMapping.set(fieldKey, field.id); + }); + + sourceTable.indexes.forEach((index) => { + const indexKey = createIndexKey({ + table: sourceTable, + indexName: index.name, + defaultSchema: sourceDefaultSchema, + }); + indexesIdMapping.set(indexKey, index.id); + }); + }); + + sourceDiagram.relationships?.forEach((relationship) => { + const relationshipKey = createRelationshipKey({ + relationshipName: relationship.name, + defaultSchema: sourceDefaultSchema, + }); + relationshipsIdMapping.set(relationshipKey, relationship.id); + }); + + sourceDiagram.dependencies?.forEach((dependency) => { + const table = sourceDiagram.tables?.find( + (t) => t.id === dependency.tableId + ); + const dependentTable = sourceDiagram.tables?.find( + (t) => t.id === dependency.dependentTableId + ); + + if (!table || !dependentTable) return; + + const dependencyKey = createDependencyKey({ + table, + dependentTable, + defaultSchema: sourceDefaultSchema, + }); + + dependenciesIdMapping.set(dependencyKey, dependency.id); + }); + + sourceDiagram.customTypes?.forEach((customType) => { + const customTypeKey = createCustomTypeKey({ + customType, + defaultSchema: sourceDefaultSchema, + }); + customTypesIdMapping.set(customTypeKey, customType.id); + }); + + // Map current ID -> new ID for target diagram entities + const targetTableIdMapping = new Map(); + const targetFieldIdMapping = new Map(); + const targetIndexIdMapping = new Map(); + const targetRelationshipIdMapping = new Map(); + const targetDependencyIdMapping = new Map(); + const targetCustomTypeIdMapping = new Map(); + + targetDiagram?.tables?.forEach((targetTable) => { + const targetKey = createTableKey({ + table: targetTable, + defaultSchema: targetDefaultSchema, + }); + const newId = tablesIdMapping.get(targetKey); + if (newId) { + targetTableIdMapping.set(targetTable.id, newId); + } + + targetTable.fields.forEach((field) => { + const fieldKey = createFieldKey({ + table: targetTable, + fieldName: field.name, + defaultSchema: targetDefaultSchema, + }); + const newFieldId = fieldsIdMapping.get(fieldKey); + if (newFieldId) { + targetFieldIdMapping.set(field.id, newFieldId); + } + }); + + targetTable.indexes.forEach((index) => { + const indexKey = createIndexKey({ + table: targetTable, + indexName: index.name, + defaultSchema: targetDefaultSchema, + }); + const newIndexId = indexesIdMapping.get(indexKey); + if (newIndexId) { + targetIndexIdMapping.set(index.id, newIndexId); + } + }); + }); + + targetDiagram.relationships?.forEach((relationship) => { + const relationshipKey = createRelationshipKey({ + relationshipName: relationship.name, + defaultSchema: targetDefaultSchema, + }); + const newId = relationshipsIdMapping.get(relationshipKey); + if (newId) { + targetRelationshipIdMapping.set(relationship.id, newId); + } + }); + + targetDiagram.dependencies?.forEach((dependency) => { + const table = targetDiagram.tables?.find( + (t) => t.id === dependency.tableId + ); + const dependentTable = targetDiagram.tables?.find( + (t) => t.id === dependency.dependentTableId + ); + + if (!table || !dependentTable) return; + + const dependencyKey = createDependencyKey({ + table, + dependentTable, + defaultSchema: targetDefaultSchema, + }); + + const newId = dependenciesIdMapping.get(dependencyKey); + if (newId) { + targetDependencyIdMapping.set(dependency.id, newId); + } + }); + + targetDiagram.customTypes?.forEach((customType) => { + const customTypeKey = createCustomTypeKey({ + customType, + defaultSchema: targetDefaultSchema, + }); + const newId = customTypesIdMapping.get(customTypeKey); + if (newId) { + targetCustomTypeIdMapping.set(customType.id, newId); + } + }); + + // Apply the ID mappings to create the final diagram + const result: Diagram = { + ...targetDiagram, + tables: targetDiagram.tables?.map((table) => { + const newTableId = targetTableIdMapping.get(table.id) ?? table.id; + + return { + ...table, + id: newTableId, + fields: table.fields.map((field) => { + const newFieldId = + targetFieldIdMapping.get(field.id) ?? field.id; + return { + ...field, + id: newFieldId, + }; + }), + indexes: table.indexes.map((index) => { + const newIndexId = + targetIndexIdMapping.get(index.id) ?? index.id; + + // Update field IDs in index + const updatedFieldIds = index.fieldIds.map((fieldId) => { + return targetFieldIdMapping.get(fieldId) ?? fieldId; + }); + + return { + ...index, + id: newIndexId, + fieldIds: updatedFieldIds, + }; + }), + }; + }), + relationships: targetDiagram.relationships?.map((relationship) => { + const newRelationshipId = + targetRelationshipIdMapping.get(relationship.id) ?? + relationship.id; + + // Update table and field IDs in relationships + const newSourceTableId = + targetTableIdMapping.get(relationship.sourceTableId) ?? + relationship.sourceTableId; + const newTargetTableId = + targetTableIdMapping.get(relationship.targetTableId) ?? + relationship.targetTableId; + const newSourceFieldId = + targetFieldIdMapping.get(relationship.sourceFieldId) ?? + relationship.sourceFieldId; + const newTargetFieldId = + targetFieldIdMapping.get(relationship.targetFieldId) ?? + relationship.targetFieldId; + + return { + ...relationship, + id: newRelationshipId, + sourceTableId: newSourceTableId, + targetTableId: newTargetTableId, + sourceFieldId: newSourceFieldId, + targetFieldId: newTargetFieldId, + }; + }), + dependencies: targetDiagram.dependencies?.map((dependency) => { + const newDependencyId = + targetDependencyIdMapping.get(dependency.id) ?? dependency.id; + const newTableId = + targetTableIdMapping.get(dependency.tableId) ?? + dependency.tableId; + const newDependentTableId = + targetTableIdMapping.get(dependency.dependentTableId) ?? + dependency.dependentTableId; + + return { + ...dependency, + id: newDependencyId, + tableId: newTableId, + dependentTableId: newDependentTableId, + }; + }), + customTypes: targetDiagram.customTypes?.map((customType) => { + const newCustomTypeId = + targetCustomTypeIdMapping.get(customType.id) ?? customType.id; + + return { + ...customType, + id: newCustomTypeId, + }; + }), + }; + + return result; +}; From e4c4a3b35484d9ece955a5aec577603dde73d634 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 21 Sep 2025 12:12:20 +0300 Subject: [PATCH 016/156] fix: add diff x,y (#928) --- src/lib/domain/diff/area-diff.ts | 12 +-- src/lib/domain/diff/diff-check/diff-check.ts | 78 ++++++++++++++++++++ src/lib/domain/diff/table-diff.ts | 12 +-- 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/lib/domain/diff/area-diff.ts b/src/lib/domain/diff/area-diff.ts index b131421fb..d4685de03 100644 --- a/src/lib/domain/diff/area-diff.ts +++ b/src/lib/domain/diff/area-diff.ts @@ -1,11 +1,13 @@ import { z } from 'zod'; import type { Area } from '../area'; -export type AreaDiffAttribute = keyof Pick; +export type AreaDiffAttribute = keyof Pick; const areaDiffAttributeSchema: z.ZodType = z.union([ z.literal('name'), z.literal('color'), + z.literal('x'), + z.literal('y'), ]); export interface AreaDiffChanged { @@ -13,8 +15,8 @@ export interface AreaDiffChanged { type: 'changed'; areaId: string; attribute: AreaDiffAttribute; - oldValue?: string | null; - newValue?: string | null; + oldValue?: string | number | null; + newValue?: string | number | null; } export const AreaDiffChangedSchema: z.ZodType = z.object({ @@ -22,8 +24,8 @@ export const AreaDiffChangedSchema: z.ZodType = z.object({ type: z.literal('changed'), areaId: z.string(), attribute: areaDiffAttributeSchema, - oldValue: z.string().or(z.null()).optional(), - newValue: z.string().or(z.null()).optional(), + oldValue: z.union([z.string(), z.number(), z.null()]).optional(), + newValue: z.union([z.string(), z.number(), z.null()]).optional(), }); export interface AreaDiffRemoved { diff --git a/src/lib/domain/diff/diff-check/diff-check.ts b/src/lib/domain/diff/diff-check/diff-check.ts index dadaa5cd3..a9dee3246 100644 --- a/src/lib/domain/diff/diff-check/diff-check.ts +++ b/src/lib/domain/diff/diff-check/diff-check.ts @@ -269,6 +269,46 @@ function compareTables({ changedTables.set(oldTable.id, true); } + + if (attributesToCheck.includes('x') && oldTable.x !== newTable.x) { + diffMap.set( + getDiffMapKey({ + diffObject: 'table', + objectId: oldTable.id, + attribute: 'x', + }), + { + object: 'table', + type: 'changed', + tableId: oldTable.id, + attribute: 'x', + newValue: newTable.x, + oldValue: oldTable.x, + } + ); + + changedTables.set(oldTable.id, true); + } + + if (attributesToCheck.includes('y') && oldTable.y !== newTable.y) { + diffMap.set( + getDiffMapKey({ + diffObject: 'table', + objectId: oldTable.id, + attribute: 'y', + }), + { + object: 'table', + type: 'changed', + tableId: oldTable.id, + attribute: 'y', + newValue: newTable.y, + oldValue: oldTable.y, + } + ); + + changedTables.set(oldTable.id, true); + } } } } @@ -788,6 +828,44 @@ function compareAreas({ ); changedAreas.set(oldArea.id, true); } + + if (attributesToCheck.includes('x') && oldArea.x !== newArea.x) { + diffMap.set( + getDiffMapKey({ + diffObject: 'area', + objectId: oldArea.id, + attribute: 'x', + }), + { + object: 'area', + type: 'changed', + areaId: oldArea.id, + attribute: 'x', + newValue: newArea.x, + oldValue: oldArea.x, + } + ); + changedAreas.set(oldArea.id, true); + } + + if (attributesToCheck.includes('y') && oldArea.y !== newArea.y) { + diffMap.set( + getDiffMapKey({ + diffObject: 'area', + objectId: oldArea.id, + attribute: 'y', + }), + { + object: 'area', + type: 'changed', + areaId: oldArea.id, + attribute: 'y', + newValue: newArea.y, + oldValue: oldArea.y, + } + ); + changedAreas.set(oldArea.id, true); + } } } } diff --git a/src/lib/domain/diff/table-diff.ts b/src/lib/domain/diff/table-diff.ts index fa492bf5e..7a8f1363c 100644 --- a/src/lib/domain/diff/table-diff.ts +++ b/src/lib/domain/diff/table-diff.ts @@ -3,13 +3,15 @@ import type { DBTable } from '../db-table'; export type TableDiffAttribute = keyof Pick< DBTable, - 'name' | 'comments' | 'color' + 'name' | 'comments' | 'color' | 'x' | 'y' >; const tableDiffAttributeSchema: z.ZodType = z.union([ z.literal('name'), z.literal('comments'), z.literal('color'), + z.literal('x'), + z.literal('y'), ]); export interface TableDiffChanged { @@ -17,8 +19,8 @@ export interface TableDiffChanged { type: 'changed'; tableId: string; attribute: TableDiffAttribute; - oldValue?: string | null; - newValue?: string | null; + oldValue?: string | number | null; + newValue?: string | number | null; } export const TableDiffChangedSchema: z.ZodType = z.object({ @@ -26,8 +28,8 @@ export const TableDiffChangedSchema: z.ZodType = z.object({ type: z.literal('changed'), tableId: z.string(), attribute: tableDiffAttributeSchema, - oldValue: z.string().or(z.null()).optional(), - newValue: z.string().or(z.null()).optional(), + oldValue: z.union([z.string(), z.number(), z.null()]).optional(), + newValue: z.union([z.string(), z.number(), z.null()]).optional(), }); export interface TableDiffRemoved { From d71b46e8b58441b475bed20d08ca54492ddc7777 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 21 Sep 2025 16:58:12 +0300 Subject: [PATCH 017/156] move utils to dir (#929) --- src/lib/utils/index.ts | 2 ++ src/lib/{ => utils}/utils.ts | 0 2 files changed, 2 insertions(+) create mode 100644 src/lib/utils/index.ts rename src/lib/{ => utils}/utils.ts (100%) diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts new file mode 100644 index 000000000..240d63482 --- /dev/null +++ b/src/lib/utils/index.ts @@ -0,0 +1,2 @@ +export * from './utils'; +export * from './apply-ids'; diff --git a/src/lib/utils.ts b/src/lib/utils/utils.ts similarity index 100% rename from src/lib/utils.ts rename to src/lib/utils/utils.ts From 47a7a73a137b87dfa6e67aff5f939cf64ccf4601 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 21 Sep 2025 18:34:29 +0300 Subject: [PATCH 018/156] fix: add tests for diff (#930) * fix: add tests for diff * fix --- .../diff-check/__tests__/diff-check.test.ts | 662 ++++++++++++++++++ src/lib/domain/diff/diff-check/diff-check.ts | 148 +++- 2 files changed, 778 insertions(+), 32 deletions(-) create mode 100644 src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts diff --git a/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts b/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts new file mode 100644 index 000000000..b0b3521a6 --- /dev/null +++ b/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts @@ -0,0 +1,662 @@ +import { describe, it, expect } from 'vitest'; +import { generateDiff } from '../diff-check'; +import type { Diagram } from '@/lib/domain/diagram'; +import type { DBTable } from '@/lib/domain/db-table'; +import type { DBField } from '@/lib/domain/db-field'; +import type { DBIndex } from '@/lib/domain/db-index'; +import type { DBRelationship } from '@/lib/domain/db-relationship'; +import type { Area } from '@/lib/domain/area'; +import { DatabaseType } from '@/lib/domain/database-type'; +import type { TableDiffChanged } from '../../table-diff'; +import type { FieldDiffChanged } from '../../field-diff'; + +// Helper function to create a mock diagram +function createMockDiagram(overrides?: Partial): Diagram { + return { + id: 'diagram-1', + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [], + relationships: [], + areas: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +// Helper function to create a mock table +function createMockTable(overrides?: Partial): DBTable { + return { + id: 'table-1', + name: 'users', + fields: [], + indexes: [], + x: 0, + y: 0, + ...overrides, + } as DBTable; +} + +// Helper function to create a mock field +function createMockField(overrides?: Partial): DBField { + return { + id: 'field-1', + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: false, + nullable: true, + unique: false, + ...overrides, + } as DBField; +} + +// Helper function to create a mock relationship +function createMockRelationship( + overrides?: Partial +): DBRelationship { + return { + id: 'rel-1', + sourceTableId: 'table-1', + targetTableId: 'table-2', + sourceFieldId: 'field-1', + targetFieldId: 'field-2', + type: 'one-to-many', + ...overrides, + } as DBRelationship; +} + +// Helper function to create a mock area +function createMockArea(overrides?: Partial): Area { + return { + id: 'area-1', + name: 'Main Area', + x: 0, + y: 0, + width: 100, + height: 100, + color: 'blue', + ...overrides, + } as Area; +} + +describe('generateDiff', () => { + describe('Basic Table Diffing', () => { + it('should detect added tables', () => { + const oldDiagram = createMockDiagram({ tables: [] }); + const newDiagram = createMockDiagram({ + tables: [createMockTable()], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('table-table-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('added'); + expect(result.changedTables.has('table-1')).toBe(true); + }); + + it('should detect removed tables', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable()], + }); + const newDiagram = createMockDiagram({ tables: [] }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('table-table-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('removed'); + expect(result.changedTables.has('table-1')).toBe(true); + }); + + it('should detect table name changes', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ name: 'users' })], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ name: 'customers' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('table-name-table-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as TableDiffChanged)?.attribute).toBe('name'); + }); + + it('should detect table position changes', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ x: 0, y: 0 })], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ x: 100, y: 200 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + attributes: { + tables: ['name', 'comments', 'color', 'x', 'y'], + }, + }, + }); + + expect(result.diffMap.size).toBe(2); + expect(result.diffMap.has('table-x-table-1')).toBe(true); + expect(result.diffMap.has('table-y-table-1')).toBe(true); + }); + }); + + describe('Field Diffing', () => { + it('should detect added fields', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ fields: [] })], + }); + const newDiagram = createMockDiagram({ + tables: [ + createMockTable({ + fields: [createMockField()], + }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('field-field-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('added'); + expect(result.changedFields.has('field-1')).toBe(true); + }); + + it('should detect removed fields', () => { + const oldDiagram = createMockDiagram({ + tables: [ + createMockTable({ + fields: [createMockField()], + }), + ], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ fields: [] })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('field-field-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('removed'); + }); + + it('should detect field type changes', () => { + const oldDiagram = createMockDiagram({ + tables: [ + createMockTable({ + fields: [ + createMockField({ + type: { id: 'integer', name: 'integer' }, + }), + ], + }), + ], + }); + const newDiagram = createMockDiagram({ + tables: [ + createMockTable({ + fields: [ + createMockField({ + type: { id: 'varchar', name: 'varchar' }, + }), + ], + }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('field-type-field-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as FieldDiffChanged)?.attribute).toBe('type'); + }); + }); + + describe('Relationship Diffing', () => { + it('should detect added relationships', () => { + const oldDiagram = createMockDiagram({ relationships: [] }); + const newDiagram = createMockDiagram({ + relationships: [createMockRelationship()], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('relationship-rel-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('added'); + }); + + it('should detect removed relationships', () => { + const oldDiagram = createMockDiagram({ + relationships: [createMockRelationship()], + }); + const newDiagram = createMockDiagram({ relationships: [] }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('relationship-rel-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('removed'); + }); + }); + + describe('Area Diffing', () => { + it('should detect added areas when includeAreas is true', () => { + const oldDiagram = createMockDiagram({ areas: [] }); + const newDiagram = createMockDiagram({ + areas: [createMockArea()], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeAreas: true, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('area-area-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('added'); + expect(result.changedAreas.has('area-1')).toBe(true); + }); + + it('should not detect area changes when includeAreas is false', () => { + const oldDiagram = createMockDiagram({ areas: [] }); + const newDiagram = createMockDiagram({ + areas: [createMockArea()], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeAreas: false, + }, + }); + + expect(result.diffMap.size).toBe(0); + }); + }); + + describe('Custom Matchers', () => { + it('should use custom table matcher to match by name', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-1', name: 'users' })], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-2', name: 'users' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + matchers: { + table: (table, tables) => + tables.find((t) => t.name === table.name), + }, + }, + }); + + // Should not detect any changes since tables match by name + expect(result.diffMap.size).toBe(0); + }); + + it('should detect changes when custom matcher finds no match', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-1', name: 'users' })], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-2', name: 'customers' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + matchers: { + table: (table, tables) => + tables.find((t) => t.name === table.name), + }, + }, + }); + + // Should detect both added and removed since names don't match + expect(result.diffMap.size).toBe(2); + expect(result.diffMap.has('table-table-1')).toBe(true); // removed + expect(result.diffMap.has('table-table-2')).toBe(true); // added + }); + + it('should use custom field matcher to match by name', () => { + const field1 = createMockField({ + id: 'field-1', + name: 'email', + nullable: true, + }); + const field2 = createMockField({ + id: 'field-2', + name: 'email', + nullable: false, + }); + + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-1', fields: [field1] })], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-1', fields: [field2] })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + matchers: { + field: (field, fields) => + fields.find((f) => f.name === field.name), + }, + }, + }); + + // With name-based matching, field-1 should match field-2 by name + // and detect the nullable change + const nullableChange = result.diffMap.get('field-nullable-field-1'); + expect(nullableChange).toBeDefined(); + expect(nullableChange?.type).toBe('changed'); + expect((nullableChange as FieldDiffChanged)?.attribute).toBe( + 'nullable' + ); + }); + + it('should use case-insensitive custom matcher', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-1', name: 'Users' })], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-2', name: 'users' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + matchers: { + table: (table, tables) => + tables.find( + (t) => + t.name.toLowerCase() === + table.name.toLowerCase() + ), + }, + }, + }); + + // With case-insensitive name matching, the tables are matched + // but the name case difference is still detected as a change + expect(result.diffMap.size).toBe(1); + const nameChange = result.diffMap.get('table-name-table-1'); + expect(nameChange).toBeDefined(); + expect(nameChange?.type).toBe('changed'); + expect((nameChange as TableDiffChanged)?.attribute).toBe('name'); + expect((nameChange as TableDiffChanged)?.oldValue).toBe('Users'); + expect((nameChange as TableDiffChanged)?.newValue).toBe('users'); + }); + }); + + describe('Filtering Options', () => { + it('should only check specified change types', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-1', name: 'users' })], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ id: 'table-2', name: 'products' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + changeTypes: { + tables: ['added'], // Only check for added tables + }, + }, + }); + + // Should only detect added table (table-2) + const addedTables = Array.from(result.diffMap.values()).filter( + (diff) => diff.type === 'added' && diff.object === 'table' + ); + expect(addedTables.length).toBe(1); + + // Should not detect removed table (table-1) + const removedTables = Array.from(result.diffMap.values()).filter( + (diff) => diff.type === 'removed' && diff.object === 'table' + ); + expect(removedTables.length).toBe(0); + }); + + it('should only check specified attributes', () => { + const oldDiagram = createMockDiagram({ + tables: [ + createMockTable({ + id: 'table-1', + name: 'users', + color: 'blue', + comments: 'old comment', + }), + ], + }); + const newDiagram = createMockDiagram({ + tables: [ + createMockTable({ + id: 'table-1', + name: 'customers', + color: 'red', + comments: 'new comment', + }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + attributes: { + tables: ['name'], // Only check name changes + }, + }, + }); + + // Should only detect name change + const nameChanges = Array.from(result.diffMap.values()).filter( + (diff) => + diff.type === 'changed' && + diff.attribute === 'name' && + diff.object === 'table' + ); + expect(nameChanges.length).toBe(1); + + // Should not detect color or comments changes + const otherChanges = Array.from(result.diffMap.values()).filter( + (diff) => + diff.type === 'changed' && + (diff.attribute === 'color' || + diff.attribute === 'comments') && + diff.object === 'table' + ); + expect(otherChanges.length).toBe(0); + }); + + it('should respect include flags', () => { + const oldDiagram = createMockDiagram({ + tables: [ + createMockTable({ + fields: [createMockField()], + indexes: [{ id: 'idx-1', name: 'idx' } as DBIndex], + }), + ], + }); + const newDiagram = createMockDiagram({ + tables: [ + createMockTable({ + fields: [], + indexes: [], + }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeFields: false, + includeIndexes: true, + }, + }); + + // Should only detect index removal, not field removal + expect(result.diffMap.has('index-idx-1')).toBe(true); + expect(result.diffMap.has('field-field-1')).toBe(false); + }); + }); + + describe('Complex Scenarios', () => { + it('should handle multiple simultaneous changes', () => { + const oldDiagram = createMockDiagram({ + tables: [ + createMockTable({ + id: 'table-1', + name: 'users', + fields: [ + createMockField({ id: 'field-1', name: 'id' }), + createMockField({ id: 'field-2', name: 'email' }), + ], + }), + createMockTable({ + id: 'table-2', + name: 'products', + }), + ], + relationships: [createMockRelationship()], + }); + + const newDiagram = createMockDiagram({ + tables: [ + createMockTable({ + id: 'table-1', + name: 'customers', // Changed name + fields: [ + createMockField({ id: 'field-1', name: 'id' }), + // Removed field-2 + createMockField({ id: 'field-3', name: 'name' }), // Added field + ], + }), + // Removed table-2 + createMockTable({ + id: 'table-3', + name: 'orders', // Added table + }), + ], + relationships: [], // Removed relationship + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + }); + + // Verify all changes are detected + expect(result.diffMap.has('table-name-table-1')).toBe(true); // Table name change + expect(result.diffMap.has('field-field-2')).toBe(true); // Removed field + expect(result.diffMap.has('field-field-3')).toBe(true); // Added field + expect(result.diffMap.has('table-table-2')).toBe(true); // Removed table + expect(result.diffMap.has('table-table-3')).toBe(true); // Added table + expect(result.diffMap.has('relationship-rel-1')).toBe(true); // Removed relationship + }); + + it('should handle empty diagrams', () => { + const emptyDiagram1 = createMockDiagram(); + const emptyDiagram2 = createMockDiagram(); + + const result = generateDiff({ + diagram: emptyDiagram1, + newDiagram: emptyDiagram2, + }); + + expect(result.diffMap.size).toBe(0); + expect(result.changedTables.size).toBe(0); + expect(result.changedFields.size).toBe(0); + expect(result.changedAreas.size).toBe(0); + }); + + it('should handle diagrams with undefined collections', () => { + const diagram1 = createMockDiagram({ + tables: undefined, + relationships: undefined, + areas: undefined, + }); + const diagram2 = createMockDiagram({ + tables: [createMockTable({ id: 'table-1' })], + relationships: [createMockRelationship({ id: 'rel-1' })], + areas: [createMockArea({ id: 'area-1' })], + }); + + const result = generateDiff({ + diagram: diagram1, + newDiagram: diagram2, + options: { + includeAreas: true, + }, + }); + + // Should detect all as added + expect(result.diffMap.has('table-table-1')).toBe(true); + expect(result.diffMap.has('relationship-rel-1')).toBe(true); + expect(result.diffMap.has('area-area-1')).toBe(true); + }); + }); +}); diff --git a/src/lib/domain/diff/diff-check/diff-check.ts b/src/lib/domain/diff/diff-check/diff-check.ts index a9dee3246..a530178f2 100644 --- a/src/lib/domain/diff/diff-check/diff-check.ts +++ b/src/lib/domain/diff/diff-check/diff-check.ts @@ -1,6 +1,9 @@ import type { Diagram } from '@/lib/domain/diagram'; import type { DBField } from '@/lib/domain/db-field'; import type { DBIndex } from '@/lib/domain/db-index'; +import type { DBTable } from '@/lib/domain/db-table'; +import type { DBRelationship } from '@/lib/domain/db-relationship'; +import type { Area } from '@/lib/domain/area'; import type { ChartDBDiff, DiffMap, DiffObject } from '@/lib/domain/diff/diff'; import type { FieldDiff, @@ -43,20 +46,22 @@ export interface GenerateDiffOptions { relationships?: RelationshipDiff['type'][]; areas?: AreaDiff['type'][]; }; + matchers?: { + table?: (table: DBTable, tables: DBTable[]) => DBTable | undefined; + field?: (field: DBField, fields: DBField[]) => DBField | undefined; + index?: (index: DBIndex, indexes: DBIndex[]) => DBIndex | undefined; + relationship?: ( + relationship: DBRelationship, + relationships: DBRelationship[] + ) => DBRelationship | undefined; + area?: (area: Area, areas: Area[]) => Area | undefined; + }; } export function generateDiff({ diagram, newDiagram, - options = { - includeTables: true, - includeFields: true, - includeIndexes: true, - includeRelationships: true, - includeAreas: false, - attributes: {}, - changeTypes: {}, - }, + options = {}, }: { diagram: Diagram; newDiagram: Diagram; @@ -67,20 +72,41 @@ export function generateDiff({ changedFields: Map; changedAreas: Map; } { + // Merge with default options + const mergedOptions: GenerateDiffOptions = { + includeTables: options.includeTables ?? true, + includeFields: options.includeFields ?? true, + includeIndexes: options.includeIndexes ?? true, + includeRelationships: options.includeRelationships ?? true, + includeAreas: options.includeAreas ?? false, + attributes: options.attributes ?? {}, + changeTypes: options.changeTypes ?? {}, + matchers: options.matchers ?? {}, + }; + const newDiffs = new Map(); const changedTables = new Map(); const changedFields = new Map(); const changedAreas = new Map(); + // Use provided matchers or default ones + const tableMatcher = mergedOptions.matchers?.table ?? defaultTableMatcher; + const fieldMatcher = mergedOptions.matchers?.field ?? defaultFieldMatcher; + const indexMatcher = mergedOptions.matchers?.index ?? defaultIndexMatcher; + const relationshipMatcher = + mergedOptions.matchers?.relationship ?? defaultRelationshipMatcher; + const areaMatcher = mergedOptions.matchers?.area ?? defaultAreaMatcher; + // Compare tables - if (options.includeTables) { + if (mergedOptions.includeTables) { compareTables({ diagram, newDiagram, diffMap: newDiffs, changedTables, - attributes: options.attributes?.tables, - changeTypes: options.changeTypes?.tables, + attributes: mergedOptions.attributes?.tables, + changeTypes: mergedOptions.changeTypes?.tables, + tableMatcher, }); } @@ -91,28 +117,33 @@ export function generateDiff({ diffMap: newDiffs, changedTables, changedFields, - options, + options: mergedOptions, + tableMatcher, + fieldMatcher, + indexMatcher, }); // Compare relationships - if (options.includeRelationships) { + if (mergedOptions.includeRelationships) { compareRelationships({ diagram, newDiagram, diffMap: newDiffs, - changeTypes: options.changeTypes?.relationships, + changeTypes: mergedOptions.changeTypes?.relationships, + relationshipMatcher, }); } // Compare areas if enabled - if (options.includeAreas) { + if (mergedOptions.includeAreas) { compareAreas({ diagram, newDiagram, diffMap: newDiffs, changedAreas, - attributes: options.attributes?.areas, - changeTypes: options.changeTypes?.areas, + attributes: mergedOptions.attributes?.areas, + changeTypes: mergedOptions.changeTypes?.areas, + areaMatcher, }); } @@ -127,6 +158,7 @@ function compareTables({ changedTables, attributes, changeTypes, + tableMatcher, }: { diagram: Diagram; newDiagram: Diagram; @@ -134,6 +166,7 @@ function compareTables({ changedTables: Map; attributes?: TableDiffAttribute[]; changeTypes?: TableDiff['type'][]; + tableMatcher: (table: DBTable, tables: DBTable[]) => DBTable | undefined; }) { const oldTables = diagram.tables || []; const newTables = newDiagram.tables || []; @@ -149,7 +182,7 @@ function compareTables({ // Check for added tables if (typesToCheck.includes('added')) { for (const newTable of newTables) { - if (!oldTables.find((t) => t.id === newTable.id)) { + if (!tableMatcher(newTable, oldTables)) { diffMap.set( getDiffMapKey({ diffObject: 'table', @@ -169,7 +202,7 @@ function compareTables({ // Check for removed tables if (typesToCheck.includes('removed')) { for (const oldTable of oldTables) { - if (!newTables.find((t) => t.id === oldTable.id)) { + if (!tableMatcher(oldTable, newTables)) { diffMap.set( getDiffMapKey({ diffObject: 'table', @@ -189,7 +222,7 @@ function compareTables({ // Check for table name, comments and color changes if (typesToCheck.includes('changed')) { for (const oldTable of oldTables) { - const newTable = newTables.find((t) => t.id === oldTable.id); + const newTable = tableMatcher(oldTable, newTables); if (!newTable) continue; @@ -321,6 +354,9 @@ function compareTableContents({ changedTables, changedFields, options, + tableMatcher, + fieldMatcher, + indexMatcher, }: { diagram: Diagram; newDiagram: Diagram; @@ -328,13 +364,16 @@ function compareTableContents({ changedTables: Map; changedFields: Map; options?: GenerateDiffOptions; + tableMatcher: (table: DBTable, tables: DBTable[]) => DBTable | undefined; + fieldMatcher: (field: DBField, fields: DBField[]) => DBField | undefined; + indexMatcher: (index: DBIndex, indexes: DBIndex[]) => DBIndex | undefined; }) { const oldTables = diagram.tables || []; const newTables = newDiagram.tables || []; // For each table that exists in both diagrams for (const oldTable of oldTables) { - const newTable = newTables.find((t) => t.id === oldTable.id); + const newTable = tableMatcher(oldTable, newTables); if (!newTable) continue; // Compare fields @@ -348,6 +387,7 @@ function compareTableContents({ changedFields, attributes: options?.attributes?.fields, changeTypes: options?.changeTypes?.fields, + fieldMatcher, }); } @@ -360,6 +400,7 @@ function compareTableContents({ diffMap, changedTables, changeTypes: options?.changeTypes?.indexes, + indexMatcher, }); } } @@ -375,6 +416,7 @@ function compareFields({ changedFields, attributes, changeTypes, + fieldMatcher, }: { tableId: string; oldFields: DBField[]; @@ -384,6 +426,7 @@ function compareFields({ changedFields: Map; attributes?: FieldDiffAttribute[]; changeTypes?: FieldDiff['type'][]; + fieldMatcher: (field: DBField, fields: DBField[]) => DBField | undefined; }) { // If changeTypes is empty array, don't check any changes if (changeTypes && changeTypes.length === 0) { @@ -395,7 +438,7 @@ function compareFields({ // Check for added fields if (typesToCheck.includes('added')) { for (const newField of newFields) { - if (!oldFields.find((f) => f.id === newField.id)) { + if (!fieldMatcher(newField, oldFields)) { diffMap.set( getDiffMapKey({ diffObject: 'field', @@ -417,7 +460,7 @@ function compareFields({ // Check for removed fields if (typesToCheck.includes('removed')) { for (const oldField of oldFields) { - if (!newFields.find((f) => f.id === oldField.id)) { + if (!fieldMatcher(oldField, newFields)) { diffMap.set( getDiffMapKey({ diffObject: 'field', @@ -440,7 +483,7 @@ function compareFields({ // Check for field changes if (typesToCheck.includes('changed')) { for (const oldField of oldFields) { - const newField = newFields.find((f) => f.id === oldField.id); + const newField = fieldMatcher(oldField, newFields); if (!newField) continue; // Compare basic field properties @@ -586,6 +629,7 @@ function compareIndexes({ diffMap, changedTables, changeTypes, + indexMatcher, }: { tableId: string; oldIndexes: DBIndex[]; @@ -593,6 +637,7 @@ function compareIndexes({ diffMap: DiffMap; changedTables: Map; changeTypes?: IndexDiff['type'][]; + indexMatcher: (index: DBIndex, indexes: DBIndex[]) => DBIndex | undefined; }) { // If changeTypes is empty array, don't check any changes if (changeTypes && changeTypes.length === 0) { @@ -604,7 +649,7 @@ function compareIndexes({ // Check for added indexes if (typesToCheck.includes('added')) { for (const newIndex of newIndexes) { - if (!oldIndexes.find((i) => i.id === newIndex.id)) { + if (!indexMatcher(newIndex, oldIndexes)) { diffMap.set( getDiffMapKey({ diffObject: 'index', @@ -625,7 +670,7 @@ function compareIndexes({ // Check for removed indexes if (typesToCheck.includes('removed')) { for (const oldIndex of oldIndexes) { - if (!newIndexes.find((i) => i.id === oldIndex.id)) { + if (!indexMatcher(oldIndex, newIndexes)) { diffMap.set( getDiffMapKey({ diffObject: 'index', @@ -650,11 +695,16 @@ function compareRelationships({ newDiagram, diffMap, changeTypes, + relationshipMatcher, }: { diagram: Diagram; newDiagram: Diagram; diffMap: DiffMap; changeTypes?: RelationshipDiff['type'][]; + relationshipMatcher: ( + relationship: DBRelationship, + relationships: DBRelationship[] + ) => DBRelationship | undefined; }) { // If changeTypes is empty array, don't check any changes if (changeTypes && changeTypes.length === 0) { @@ -669,7 +719,7 @@ function compareRelationships({ // Check for added relationships if (typesToCheck.includes('added')) { for (const newRelationship of newRelationships) { - if (!oldRelationships.find((r) => r.id === newRelationship.id)) { + if (!relationshipMatcher(newRelationship, oldRelationships)) { diffMap.set( getDiffMapKey({ diffObject: 'relationship', @@ -688,7 +738,7 @@ function compareRelationships({ // Check for removed relationships if (typesToCheck.includes('removed')) { for (const oldRelationship of oldRelationships) { - if (!newRelationships.find((r) => r.id === oldRelationship.id)) { + if (!relationshipMatcher(oldRelationship, newRelationships)) { diffMap.set( getDiffMapKey({ diffObject: 'relationship', @@ -713,6 +763,7 @@ function compareAreas({ changedAreas, attributes, changeTypes, + areaMatcher, }: { diagram: Diagram; newDiagram: Diagram; @@ -720,6 +771,7 @@ function compareAreas({ changedAreas: Map; attributes?: AreaDiffAttribute[]; changeTypes?: AreaDiff['type'][]; + areaMatcher: (area: Area, areas: Area[]) => Area | undefined; }) { const oldAreas = diagram.areas || []; const newAreas = newDiagram.areas || []; @@ -735,7 +787,7 @@ function compareAreas({ // Check for added areas if (typesToCheck.includes('added')) { for (const newArea of newAreas) { - if (!oldAreas.find((a) => a.id === newArea.id)) { + if (!areaMatcher(newArea, oldAreas)) { diffMap.set( getDiffMapKey({ diffObject: 'area', @@ -755,7 +807,7 @@ function compareAreas({ // Check for removed areas if (typesToCheck.includes('removed')) { for (const oldArea of oldAreas) { - if (!newAreas.find((a) => a.id === oldArea.id)) { + if (!areaMatcher(oldArea, newAreas)) { diffMap.set( getDiffMapKey({ diffObject: 'area', @@ -775,7 +827,7 @@ function compareAreas({ // Check for area name and color changes if (typesToCheck.includes('changed')) { for (const oldArea of oldAreas) { - const newArea = newAreas.find((a) => a.id === oldArea.id); + const newArea = areaMatcher(oldArea, newAreas); if (!newArea) continue; @@ -869,3 +921,35 @@ function compareAreas({ } } } + +const defaultTableMatcher = ( + table: DBTable, + tables: DBTable[] +): DBTable | undefined => { + return tables.find((t) => t.id === table.id); +}; + +const defaultFieldMatcher = ( + field: DBField, + fields: DBField[] +): DBField | undefined => { + return fields.find((f) => f.id === field.id); +}; + +const defaultIndexMatcher = ( + index: DBIndex, + indexes: DBIndex[] +): DBIndex | undefined => { + return indexes.find((i) => i.id === index.id); +}; + +const defaultRelationshipMatcher = ( + relationship: DBRelationship, + relationships: DBRelationship[] +): DBRelationship | undefined => { + return relationships.find((r) => r.id === relationship.id); +}; + +const defaultAreaMatcher = (area: Area, areas: Area[]): Area | undefined => { + return areas.find((a) => a.id === area.id); +}; From 98f6edd5c8a8e9130e892b2d841744e0cf63a7bf Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Tue, 23 Sep 2025 11:12:46 +0300 Subject: [PATCH 019/156] fix: add areas width and height + table width to diff check (#931) * fix: add areas width and height + table width to diff check * fix --- src/lib/domain/diff/area-diff.ts | 7 +- .../diff-check/__tests__/diff-check.test.ts | 221 ++++++++++++++++++ src/lib/domain/diff/diff-check/diff-check.ts | 67 ++++++ src/lib/domain/diff/table-diff.ts | 3 +- 4 files changed, 296 insertions(+), 2 deletions(-) diff --git a/src/lib/domain/diff/area-diff.ts b/src/lib/domain/diff/area-diff.ts index d4685de03..02bea1dcd 100644 --- a/src/lib/domain/diff/area-diff.ts +++ b/src/lib/domain/diff/area-diff.ts @@ -1,13 +1,18 @@ import { z } from 'zod'; import type { Area } from '../area'; -export type AreaDiffAttribute = keyof Pick; +export type AreaDiffAttribute = keyof Pick< + Area, + 'name' | 'color' | 'x' | 'y' | 'width' | 'height' +>; const areaDiffAttributeSchema: z.ZodType = z.union([ z.literal('name'), z.literal('color'), z.literal('x'), z.literal('y'), + z.literal('width'), + z.literal('height'), ]); export interface AreaDiffChanged { diff --git a/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts b/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts index b0b3521a6..b161b27e9 100644 --- a/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts +++ b/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts @@ -9,6 +9,7 @@ import type { Area } from '@/lib/domain/area'; import { DatabaseType } from '@/lib/domain/database-type'; import type { TableDiffChanged } from '../../table-diff'; import type { FieldDiffChanged } from '../../field-diff'; +import type { AreaDiffChanged } from '../../area-diff'; // Helper function to create a mock diagram function createMockDiagram(overrides?: Partial): Diagram { @@ -160,6 +161,62 @@ describe('generateDiff', () => { expect(result.diffMap.has('table-x-table-1')).toBe(true); expect(result.diffMap.has('table-y-table-1')).toBe(true); }); + + it('should detect table width changes', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ width: 150 })], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ width: 250 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + attributes: { + tables: ['width'], + }, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('table-width-table-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as TableDiffChanged)?.attribute).toBe('width'); + expect((diff as TableDiffChanged)?.oldValue).toBe(150); + expect((diff as TableDiffChanged)?.newValue).toBe(250); + }); + + it('should detect multiple table dimension changes', () => { + const oldDiagram = createMockDiagram({ + tables: [createMockTable({ x: 0, y: 0, width: 100 })], + }); + const newDiagram = createMockDiagram({ + tables: [createMockTable({ x: 50, y: 75, width: 200 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + attributes: { + tables: ['x', 'y', 'width'], + }, + }, + }); + + expect(result.diffMap.size).toBe(3); + expect(result.diffMap.has('table-x-table-1')).toBe(true); + expect(result.diffMap.has('table-y-table-1')).toBe(true); + expect(result.diffMap.has('table-width-table-1')).toBe(true); + + const widthDiff = result.diffMap.get('table-width-table-1'); + expect(widthDiff?.type).toBe('changed'); + expect((widthDiff as TableDiffChanged)?.oldValue).toBe(100); + expect((widthDiff as TableDiffChanged)?.newValue).toBe(200); + }); }); describe('Field Diffing', () => { @@ -321,6 +378,92 @@ describe('generateDiff', () => { expect(result.diffMap.size).toBe(0); }); + + it('should detect area width changes', () => { + const oldDiagram = createMockDiagram({ + areas: [createMockArea({ width: 100 })], + }); + const newDiagram = createMockDiagram({ + areas: [createMockArea({ width: 200 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeAreas: true, + attributes: { + areas: ['width'], + }, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('area-width-area-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as AreaDiffChanged)?.attribute).toBe('width'); + expect((diff as AreaDiffChanged)?.oldValue).toBe(100); + expect((diff as AreaDiffChanged)?.newValue).toBe(200); + }); + + it('should detect area height changes', () => { + const oldDiagram = createMockDiagram({ + areas: [createMockArea({ height: 100 })], + }); + const newDiagram = createMockDiagram({ + areas: [createMockArea({ height: 300 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeAreas: true, + attributes: { + areas: ['height'], + }, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('area-height-area-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as AreaDiffChanged)?.attribute).toBe('height'); + expect((diff as AreaDiffChanged)?.oldValue).toBe(100); + expect((diff as AreaDiffChanged)?.newValue).toBe(300); + }); + + it('should detect multiple area dimension changes', () => { + const oldDiagram = createMockDiagram({ + areas: [ + createMockArea({ x: 0, y: 0, width: 100, height: 100 }), + ], + }); + const newDiagram = createMockDiagram({ + areas: [ + createMockArea({ x: 50, y: 50, width: 200, height: 300 }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeAreas: true, + attributes: { + areas: ['x', 'y', 'width', 'height'], + }, + }, + }); + + expect(result.diffMap.size).toBe(4); + expect(result.diffMap.has('area-x-area-1')).toBe(true); + expect(result.diffMap.has('area-y-area-1')).toBe(true); + expect(result.diffMap.has('area-width-area-1')).toBe(true); + expect(result.diffMap.has('area-height-area-1')).toBe(true); + }); }); describe('Custom Matchers', () => { @@ -565,6 +708,84 @@ describe('generateDiff', () => { }); describe('Complex Scenarios', () => { + it('should detect all dimensional changes for tables and areas', () => { + const oldDiagram = createMockDiagram({ + tables: [ + createMockTable({ + id: 'table-1', + x: 0, + y: 0, + width: 100, + }), + ], + areas: [ + createMockArea({ + id: 'area-1', + x: 0, + y: 0, + width: 200, + height: 150, + }), + ], + }); + + const newDiagram = createMockDiagram({ + tables: [ + createMockTable({ + id: 'table-1', + x: 10, + y: 20, + width: 120, + }), + ], + areas: [ + createMockArea({ + id: 'area-1', + x: 25, + y: 35, + width: 250, + height: 175, + }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeAreas: true, + attributes: { + tables: ['x', 'y', 'width'], + areas: ['x', 'y', 'width', 'height'], + }, + }, + }); + + // Table dimensional changes + expect(result.diffMap.has('table-x-table-1')).toBe(true); + expect(result.diffMap.has('table-y-table-1')).toBe(true); + expect(result.diffMap.has('table-width-table-1')).toBe(true); + + // Area dimensional changes + expect(result.diffMap.has('area-x-area-1')).toBe(true); + expect(result.diffMap.has('area-y-area-1')).toBe(true); + expect(result.diffMap.has('area-width-area-1')).toBe(true); + expect(result.diffMap.has('area-height-area-1')).toBe(true); + + // Verify the correct values + const tableWidthDiff = result.diffMap.get('table-width-table-1'); + expect((tableWidthDiff as TableDiffChanged)?.oldValue).toBe(100); + expect((tableWidthDiff as TableDiffChanged)?.newValue).toBe(120); + + const areaWidthDiff = result.diffMap.get('area-width-area-1'); + expect((areaWidthDiff as AreaDiffChanged)?.oldValue).toBe(200); + expect((areaWidthDiff as AreaDiffChanged)?.newValue).toBe(250); + + const areaHeightDiff = result.diffMap.get('area-height-area-1'); + expect((areaHeightDiff as AreaDiffChanged)?.oldValue).toBe(150); + expect((areaHeightDiff as AreaDiffChanged)?.newValue).toBe(175); + }); + it('should handle multiple simultaneous changes', () => { const oldDiagram = createMockDiagram({ tables: [ diff --git a/src/lib/domain/diff/diff-check/diff-check.ts b/src/lib/domain/diff/diff-check/diff-check.ts index a530178f2..62c949e44 100644 --- a/src/lib/domain/diff/diff-check/diff-check.ts +++ b/src/lib/domain/diff/diff-check/diff-check.ts @@ -342,6 +342,29 @@ function compareTables({ changedTables.set(oldTable.id, true); } + + if ( + attributesToCheck.includes('width') && + oldTable.width !== newTable.width + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'table', + objectId: oldTable.id, + attribute: 'width', + }), + { + object: 'table', + type: 'changed', + tableId: oldTable.id, + attribute: 'width', + newValue: newTable.width, + oldValue: oldTable.width, + } + ); + + changedTables.set(oldTable.id, true); + } } } } @@ -918,6 +941,50 @@ function compareAreas({ ); changedAreas.set(oldArea.id, true); } + + if ( + attributesToCheck.includes('width') && + oldArea.width !== newArea.width + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'area', + objectId: oldArea.id, + attribute: 'width', + }), + { + object: 'area', + type: 'changed', + areaId: oldArea.id, + attribute: 'width', + newValue: newArea.width, + oldValue: oldArea.width, + } + ); + changedAreas.set(oldArea.id, true); + } + + if ( + attributesToCheck.includes('height') && + oldArea.height !== newArea.height + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'area', + objectId: oldArea.id, + attribute: 'height', + }), + { + object: 'area', + type: 'changed', + areaId: oldArea.id, + attribute: 'height', + newValue: newArea.height, + oldValue: oldArea.height, + } + ); + changedAreas.set(oldArea.id, true); + } } } } diff --git a/src/lib/domain/diff/table-diff.ts b/src/lib/domain/diff/table-diff.ts index 7a8f1363c..faf22506b 100644 --- a/src/lib/domain/diff/table-diff.ts +++ b/src/lib/domain/diff/table-diff.ts @@ -3,7 +3,7 @@ import type { DBTable } from '../db-table'; export type TableDiffAttribute = keyof Pick< DBTable, - 'name' | 'comments' | 'color' | 'x' | 'y' + 'name' | 'comments' | 'color' | 'x' | 'y' | 'width' >; const tableDiffAttributeSchema: z.ZodType = z.union([ @@ -12,6 +12,7 @@ const tableDiffAttributeSchema: z.ZodType = z.union([ z.literal('color'), z.literal('x'), z.literal('y'), + z.literal('width'), ]); export interface TableDiffChanged { From 2dc1a6fc7519e0a455b0e1306601195deb156c96 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Tue, 23 Sep 2025 11:28:08 +0300 Subject: [PATCH 020/156] fix: move area utils (#932) --- .../editor-page/canvas => lib/utils}/area-utils.ts | 11 +++++++---- src/lib/utils/index.ts | 1 + src/pages/editor-page/canvas/canvas.tsx | 5 ++++- 3 files changed, 12 insertions(+), 5 deletions(-) rename src/{pages/editor-page/canvas => lib/utils}/area-utils.ts (87%) diff --git a/src/pages/editor-page/canvas/area-utils.ts b/src/lib/utils/area-utils.ts similarity index 87% rename from src/pages/editor-page/canvas/area-utils.ts rename to src/lib/utils/area-utils.ts index 7d8059512..22f423749 100644 --- a/src/pages/editor-page/canvas/area-utils.ts +++ b/src/lib/utils/area-utils.ts @@ -1,13 +1,13 @@ import type { DBTable } from '@/lib/domain/db-table'; import type { Area } from '@/lib/domain/area'; -import { calcTableHeight } from '@/lib/domain/db-table'; +import { calcTableHeight, MIN_TABLE_SIZE } from '@/lib/domain/db-table'; /** * Check if a table is inside an area based on their positions and dimensions */ -const isTableInsideArea = (table: DBTable, area: Area): boolean => { +export const isTableInsideArea = (table: DBTable, area: Area): boolean => { // Get table dimensions (assuming default width if not specified) - const tableWidth = table.width ?? 224; // MIN_TABLE_SIZE from db-table.ts + const tableWidth = table.width ?? MIN_TABLE_SIZE; const tableHeight = calcTableHeight(table); // Check if table's top-left corner is inside the area @@ -33,7 +33,10 @@ const isTableInsideArea = (table: DBTable, area: Area): boolean => { /** * Find which area contains a table */ -const findContainingArea = (table: DBTable, areas: Area[]): Area | null => { +export const findContainingArea = ( + table: DBTable, + areas: Area[] +): Area | null => { // Sort areas by order (if available) to prioritize top-most areas const sortedAreas = [...areas].sort( (a, b) => (b.order ?? 0) - (a.order ?? 0) diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 240d63482..72761db2e 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,2 +1,3 @@ export * from './utils'; export * from './apply-ids'; +export * from './area-utils'; diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index 6755faf7b..8a396e0b5 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -79,7 +79,10 @@ import { useCanvas } from '@/hooks/use-canvas'; import type { AreaNodeType } from './area-node/area-node'; import { AreaNode } from './area-node/area-node'; import type { Area } from '@/lib/domain/area'; -import { updateTablesParentAreas, getTablesInArea } from './area-utils'; +import { + updateTablesParentAreas, + getTablesInArea, +} from '@/lib/utils/area-utils'; import { CanvasFilter } from './canvas-filter/canvas-filter'; import { useHotkeys } from 'react-hotkeys-hook'; import { ShowAllButton } from './show-all-button'; From c567c0a5f39157b2c430e92192b6750304d7a834 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Wed, 24 Sep 2025 14:14:49 +0300 Subject: [PATCH 021/156] fix: remove many to many rel option (#933) --- .../relationship-list-item-content.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/editor-page/side-panel/refs-section/refs-list/relationship-list-item/relationship-list-item-content/relationship-list-item-content.tsx b/src/pages/editor-page/side-panel/refs-section/refs-list/relationship-list-item/relationship-list-item-content/relationship-list-item-content.tsx index 32a1b86da..61fa4479e 100644 --- a/src/pages/editor-page/side-panel/refs-section/refs-list/relationship-list-item/relationship-list-item-content/relationship-list-item-content.tsx +++ b/src/pages/editor-page/side-panel/refs-section/refs-list/relationship-list-item/relationship-list-item-content/relationship-list-item-content.tsx @@ -173,9 +173,11 @@ export const RelationshipListItemContent: React.FC< {t('relationship_type.many_to_one')} - - {t('relationship_type.many_to_many')} - + {relationshipType === 'many_to_many' ? ( + + {t('relationship_type.many_to_many')} + + ) : null} From c9ac8929c5a14e585f2d6562b20841f95d0fecc1 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sat, 27 Sep 2025 15:57:34 +0300 Subject: [PATCH 022/156] chore(main): release 1.16.0 (#881) --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e409ada..ad4c9e37e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [1.16.0](https://github.com/chartdb/chartdb/compare/v1.15.1...v1.16.0) (2025-09-24) + + +### Features + +* add area context menu and UI improvements ([#918](https://github.com/chartdb/chartdb/issues/918)) ([d09379e](https://github.com/chartdb/chartdb/commit/d09379e8be0fa3c83ca77ff62ae815fe4db9869b)) +* add quick table mode on canvas ([#915](https://github.com/chartdb/chartdb/issues/915)) ([8954d89](https://github.com/chartdb/chartdb/commit/8954d893bbfee45bb311380115fb14ebbf3a3133)) +* add zoom navigation buttons to canvas filter for tables and areas ([#903](https://github.com/chartdb/chartdb/issues/903)) ([a0fb1ed](https://github.com/chartdb/chartdb/commit/a0fb1ed08ba18b66354fa3498d610097a83d4afc)) +* **import-db:** add DBML syntax to import database dialog ([#768](https://github.com/chartdb/chartdb/issues/768)) ([af3638d](https://github.com/chartdb/chartdb/commit/af3638da7a9b70f281ceaddbc2f712a713d90cda)) + + +### Bug Fixes + +* add areas width and height + table width to diff check ([#931](https://github.com/chartdb/chartdb/issues/931)) ([98f6edd](https://github.com/chartdb/chartdb/commit/98f6edd5c8a8e9130e892b2d841744e0cf63a7bf)) +* add diff x,y ([#928](https://github.com/chartdb/chartdb/issues/928)) ([e4c4a3b](https://github.com/chartdb/chartdb/commit/e4c4a3b35484d9ece955a5aec577603dde73d634)) +* add support for ALTER TABLE ADD COLUMN in PostgreSQL importer ([#892](https://github.com/chartdb/chartdb/issues/892)) ([ec6e46f](https://github.com/chartdb/chartdb/commit/ec6e46fe81ea1806c179c50a4c5779d8596008aa)) +* add tests for diff ([#930](https://github.com/chartdb/chartdb/issues/930)) ([47a7a73](https://github.com/chartdb/chartdb/commit/47a7a73a137b87dfa6e67aff5f939cf64ccf4601)) +* dbml edit mode glitch ([#925](https://github.com/chartdb/chartdb/issues/925)) ([93d72a8](https://github.com/chartdb/chartdb/commit/93d72a896bab9aa79d8ea2f876126887e432214c)) +* dbml export default time bug ([#922](https://github.com/chartdb/chartdb/issues/922)) ([bc82f9d](https://github.com/chartdb/chartdb/commit/bc82f9d6a8fe4de2f7e0fc465e0a20c5dbf8f41d)) +* dbml export renaming fields bug ([#921](https://github.com/chartdb/chartdb/issues/921)) ([26dc299](https://github.com/chartdb/chartdb/commit/26dc299cd28e9890d191c13f84a15ac38ae48b11)) +* **dbml:** export array fields without quotes ([#911](https://github.com/chartdb/chartdb/issues/911)) ([5e81c18](https://github.com/chartdb/chartdb/commit/5e81c1848aaa911990e1e881d62525f5254d6d34)) +* diff logic ([#927](https://github.com/chartdb/chartdb/issues/927)) ([1b8d51b](https://github.com/chartdb/chartdb/commit/1b8d51b73c4ed4b7c5929adcb17a44927c7defca)) +* export dbml issues after upgrade version ([#883](https://github.com/chartdb/chartdb/issues/883)) ([07937a2](https://github.com/chartdb/chartdb/commit/07937a2f51708b1c10b45c2bd1f9a9acf5c3f708)) +* export sql + import metadata lib ([#902](https://github.com/chartdb/chartdb/issues/902)) ([ffddcdc](https://github.com/chartdb/chartdb/commit/ffddcdcc987bacb0e0d7e8dea27d08d3a8c5a8c8)) +* handle bidirectional relationships in DBML export ([#924](https://github.com/chartdb/chartdb/issues/924)) ([9991077](https://github.com/chartdb/chartdb/commit/99910779789a9c6ef113d06bc3de31e35b9b04d1)) +* import dbml set pk field unique ([#920](https://github.com/chartdb/chartdb/issues/920)) ([d6ba4a4](https://github.com/chartdb/chartdb/commit/d6ba4a40749d85d2703f120600df4345dab3c561)) +* improve SQL default value parsing for PostgreSQL, MySQL, and SQL Server with proper type handling and casting support ([#900](https://github.com/chartdb/chartdb/issues/900)) ([fe9ef27](https://github.com/chartdb/chartdb/commit/fe9ef275b8619dcfd7e57541a62a6237a16d29a8)) +* move area utils ([#932](https://github.com/chartdb/chartdb/issues/932)) ([2dc1a6f](https://github.com/chartdb/chartdb/commit/2dc1a6fc7519e0a455b0e1306601195deb156c96)) +* move auto arrange to toolbar ([#904](https://github.com/chartdb/chartdb/issues/904)) ([b016a70](https://github.com/chartdb/chartdb/commit/b016a70691bc22af5720b4de683e8c9353994fcc)) +* remove general db creation ([#901](https://github.com/chartdb/chartdb/issues/901)) ([df89f0b](https://github.com/chartdb/chartdb/commit/df89f0b6b9ba3fcc8b05bae4f60c0dc4ad1d2215)) +* remove many to many rel option ([#933](https://github.com/chartdb/chartdb/issues/933)) ([c567c0a](https://github.com/chartdb/chartdb/commit/c567c0a5f39157b2c430e92192b6750304d7a834)) +* reset increment and default when change field ([#896](https://github.com/chartdb/chartdb/issues/896)) ([e5e1d59](https://github.com/chartdb/chartdb/commit/e5e1d5932762422ea63acfd6cf9fe4f03aa822f7)) +* **sql-import:** handle SQL Server DDL with multiple tables, inline foreign keys, and case-insensitive field matching ([#897](https://github.com/chartdb/chartdb/issues/897)) ([2a64dee](https://github.com/chartdb/chartdb/commit/2a64deebb87a11ee3892024c3273d682bb86f7ef)) +* **sql-import:** support ALTER TABLE ALTER COLUMN TYPE in PostgreSQL importer ([#895](https://github.com/chartdb/chartdb/issues/895)) ([aa29061](https://github.com/chartdb/chartdb/commit/aa290615caf806d7d0374c848d50b4636fde7e96)) +* **sqlite:** improve parser to handle tables without column types and fix column detection ([#914](https://github.com/chartdb/chartdb/issues/914)) ([d3dbf41](https://github.com/chartdb/chartdb/commit/d3dbf41894d74f0ffce9afe3bd810f065aa53017)) +* trigger edit table on canvas from context menu ([#919](https://github.com/chartdb/chartdb/issues/919)) ([bdc41c0](https://github.com/chartdb/chartdb/commit/bdc41c0b74d9d9918e7b6cd2152fa07c0c58ce60)) +* update deps vulns ([#909](https://github.com/chartdb/chartdb/issues/909)) ([2bd9ca2](https://github.com/chartdb/chartdb/commit/2bd9ca25b2c7b1f053ff4fdc8c5cfc1b0e65901d)) +* upgrade dbml lib ([#880](https://github.com/chartdb/chartdb/issues/880)) ([d8e0bc7](https://github.com/chartdb/chartdb/commit/d8e0bc7db8881971ddaea7177bcebee13cc865f6)) + ## [1.15.1](https://github.com/chartdb/chartdb/compare/v1.15.0...v1.15.1) (2025-08-27) diff --git a/package-lock.json b/package-lock.json index 790baabd7..86fa1d9e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chartdb", - "version": "1.15.1", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chartdb", - "version": "1.15.1", + "version": "1.16.0", "dependencies": { "@ai-sdk/openai": "^0.0.51", "@dbml/core": "^3.13.9", diff --git a/package.json b/package.json index d3fb7a771..a37841767 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chartdb", "private": true, - "version": "1.15.1", + "version": "1.16.0", "type": "module", "scripts": { "dev": "vite", From bb033091b1f64b888822be1423a80f16f5314f6b Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 28 Sep 2025 17:02:56 +0300 Subject: [PATCH 023/156] fix: dbml diff fields types preview (#934) --- .../chartdb-context/chartdb-provider.tsx | 8 +-- src/context/diff-context/diff-context.tsx | 56 ++++++++++++++---- src/context/diff-context/diff-provider.tsx | 59 ++++++++++++++----- .../canvas/table-node/table-node-field.tsx | 49 +++++++++------ .../canvas/table-node/table-node.tsx | 6 +- 5 files changed, 124 insertions(+), 54 deletions(-) diff --git a/src/context/chartdb-context/chartdb-provider.tsx b/src/context/chartdb-context/chartdb-provider.tsx index b712cf8c9..58d46b388 100644 --- a/src/context/chartdb-context/chartdb-provider.tsx +++ b/src/context/chartdb-context/chartdb-provider.tsx @@ -74,10 +74,10 @@ export const ChartDBProvider: React.FC< useState(); const diffCalculatedHandler = useCallback((event: DiffCalculatedEvent) => { - const { tablesAdded, fieldsAdded, relationshipsAdded } = event.data; + const { tablesToAdd, fieldsToAdd, relationshipsToAdd } = event.data; setTables((tables) => - [...tables, ...(tablesAdded ?? [])].map((table) => { - const fields = fieldsAdded.get(table.id); + [...tables, ...(tablesToAdd ?? [])].map((table) => { + const fields = fieldsToAdd.get(table.id); return fields ? { ...table, fields: [...table.fields, ...fields] } : table; @@ -85,7 +85,7 @@ export const ChartDBProvider: React.FC< ); setRelationships((relationships) => [ ...relationships, - ...(relationshipsAdded ?? []), + ...(relationshipsToAdd ?? []), ]); }, []); diff --git a/src/context/diff-context/diff-context.tsx b/src/context/diff-context/diff-context.tsx index 491fa045a..bb1532433 100644 --- a/src/context/diff-context/diff-context.tsx +++ b/src/context/diff-context/diff-context.tsx @@ -15,9 +15,9 @@ export type DiffEventBase = { }; export type DiffCalculatedData = { - tablesAdded: DBTable[]; - fieldsAdded: Map; - relationshipsAdded: DBRelationship[]; + tablesToAdd: DBTable[]; + fieldsToAdd: Map; + relationshipsToAdd: DBRelationship[]; }; export type DiffCalculatedEvent = DiffEventBase< @@ -44,15 +44,21 @@ export interface DiffContext { options?: { summaryOnly?: boolean; }; - }) => void; + }) => { foundDiff: boolean }; resetDiff: () => void; // table diff checkIfTableHasChange: ({ tableId }: { tableId: string }) => boolean; checkIfNewTable: ({ tableId }: { tableId: string }) => boolean; checkIfTableRemoved: ({ tableId }: { tableId: string }) => boolean; - getTableNewName: ({ tableId }: { tableId: string }) => string | null; - getTableNewColor: ({ tableId }: { tableId: string }) => string | null; + getTableNewName: ({ tableId }: { tableId: string }) => { + old: string; + new: string; + } | null; + getTableNewColor: ({ tableId }: { tableId: string }) => { + old: string; + new: string; + } | null; // field diff checkIfFieldHasChange: ({ @@ -64,17 +70,41 @@ export interface DiffContext { }) => boolean; checkIfFieldRemoved: ({ fieldId }: { fieldId: string }) => boolean; checkIfNewField: ({ fieldId }: { fieldId: string }) => boolean; - getFieldNewName: ({ fieldId }: { fieldId: string }) => string | null; - getFieldNewType: ({ fieldId }: { fieldId: string }) => DataType | null; - getFieldNewPrimaryKey: ({ fieldId }: { fieldId: string }) => boolean | null; - getFieldNewNullable: ({ fieldId }: { fieldId: string }) => boolean | null; + getFieldNewName: ({ + fieldId, + }: { + fieldId: string; + }) => { old: string; new: string } | null; + getFieldNewType: ({ + fieldId, + }: { + fieldId: string; + }) => { old: DataType; new: DataType } | null; + getFieldNewPrimaryKey: ({ + fieldId, + }: { + fieldId: string; + }) => { old: boolean; new: boolean } | null; + getFieldNewNullable: ({ + fieldId, + }: { + fieldId: string; + }) => { old: boolean; new: boolean } | null; getFieldNewCharacterMaximumLength: ({ fieldId, }: { fieldId: string; - }) => string | null; - getFieldNewScale: ({ fieldId }: { fieldId: string }) => number | null; - getFieldNewPrecision: ({ fieldId }: { fieldId: string }) => number | null; + }) => { old: string; new: string } | null; + getFieldNewScale: ({ + fieldId, + }: { + fieldId: string; + }) => { old: number; new: number } | null; + getFieldNewPrecision: ({ + fieldId, + }: { + fieldId: string; + }) => { old: number; new: number } | null; // relationship diff checkIfNewRelationship: ({ diff --git a/src/context/diff-context/diff-provider.tsx b/src/context/diff-context/diff-provider.tsx index b11e8bc17..5181234b1 100644 --- a/src/context/diff-context/diff-provider.tsx +++ b/src/context/diff-context/diff-provider.tsx @@ -36,7 +36,7 @@ export const DiffProvider: React.FC = ({ const events = useEventEmitter(); - const generateNewFieldsMap = useCallback( + const generateFieldsToAddMap = useCallback( ({ diffMap, newDiagram, @@ -66,7 +66,7 @@ export const DiffProvider: React.FC = ({ [] ); - const findNewRelationships = useCallback( + const findRelationshipsToAdd = useCallback( ({ diffMap, newDiagram, @@ -101,7 +101,7 @@ export const DiffProvider: React.FC = ({ diffMap: DiffMap; }): DiffCalculatedData => { return { - tablesAdded: + tablesToAdd: newDiagram?.tables?.filter((table) => { const tableKey = getDiffMapKey({ diffObject: 'table', @@ -114,17 +114,17 @@ export const DiffProvider: React.FC = ({ ); }) ?? [], - fieldsAdded: generateNewFieldsMap({ + fieldsToAdd: generateFieldsToAddMap({ diffMap: diffMap, newDiagram: newDiagram, }), - relationshipsAdded: findNewRelationships({ + relationshipsToAdd: findRelationshipsToAdd({ diffMap: diffMap, newDiagram: newDiagram, }), }; }, - [findNewRelationships, generateNewFieldsMap] + [findRelationshipsToAdd, generateFieldsToAddMap] ); const calculateDiff: DiffContext['calculateDiff'] = useCallback( @@ -149,6 +149,8 @@ export const DiffProvider: React.FC = ({ newDiagram: newDiagramArg, }), }); + + return { foundDiff: !!newDiffs.size }; }, [setDiffMap, events, generateDiffCalculatedData] ); @@ -165,7 +167,10 @@ export const DiffProvider: React.FC = ({ const diff = diffMap.get(tableNameKey); if (diff?.type === 'changed') { - return diff.newValue as string; + return { + new: diff.newValue as string, + old: diff.oldValue as string, + }; } } @@ -186,7 +191,10 @@ export const DiffProvider: React.FC = ({ const diff = diffMap.get(tableColorKey); if (diff?.type === 'changed') { - return diff.newValue as string; + return { + new: diff.newValue as string, + old: diff.oldValue as string, + }; } } return null; @@ -277,7 +285,10 @@ export const DiffProvider: React.FC = ({ const diff = diffMap.get(fieldKey); if (diff?.type === 'changed') { - return diff.newValue as string; + return { + old: diff.oldValue as string, + new: diff.newValue as string, + }; } } @@ -298,7 +309,10 @@ export const DiffProvider: React.FC = ({ const diff = diffMap.get(fieldKey); if (diff?.type === 'changed') { - return diff.newValue as DataType; + return { + old: diff.oldValue as DataType, + new: diff.newValue as DataType, + }; } } @@ -321,7 +335,10 @@ export const DiffProvider: React.FC = ({ const diff = diffMap.get(fieldKey); if (diff?.type === 'changed') { - return diff.newValue as boolean; + return { + old: diff.oldValue as boolean, + new: diff.newValue as boolean, + }; } } @@ -342,7 +359,10 @@ export const DiffProvider: React.FC = ({ const diff = diffMap.get(fieldKey); if (diff?.type === 'changed') { - return diff.newValue as boolean; + return { + old: diff.oldValue as boolean, + new: diff.newValue as boolean, + }; } } @@ -365,7 +385,10 @@ export const DiffProvider: React.FC = ({ const diff = diffMap.get(fieldKey); if (diff?.type === 'changed') { - return diff.newValue as string; + return { + old: diff.oldValue as string, + new: diff.newValue as string, + }; } } @@ -386,7 +409,10 @@ export const DiffProvider: React.FC = ({ const diff = diffMap.get(fieldKey); if (diff?.type === 'changed') { - return diff.newValue as number; + return { + old: diff.oldValue as number, + new: diff.newValue as number, + }; } } @@ -409,7 +435,10 @@ export const DiffProvider: React.FC = ({ const diff = diffMap.get(fieldKey); if (diff?.type === 'changed') { - return diff.newValue as number; + return { + old: diff.oldValue as number, + new: diff.newValue as number, + }; } } diff --git a/src/pages/editor-page/canvas/table-node/table-node-field.tsx b/src/pages/editor-page/canvas/table-node/table-node-field.tsx index 814294577..8d54ee96b 100644 --- a/src/pages/editor-page/canvas/table-node/table-node-field.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node-field.tsx @@ -159,13 +159,17 @@ export const TableNodeField: React.FC = React.memo( const [diffState, setDiffState] = useState<{ isDiffFieldRemoved: boolean; isDiffNewField: boolean; - fieldDiffChangedName: string | null; - fieldDiffChangedType: DBField['type'] | null; - fieldDiffChangedNullable: boolean | null; - fieldDiffChangedCharacterMaximumLength: string | null; - fieldDiffChangedScale: number | null; - fieldDiffChangedPrecision: number | null; - fieldDiffChangedPrimaryKey: boolean | null; + fieldDiffChangedName: ReturnType; + fieldDiffChangedType: ReturnType; + fieldDiffChangedNullable: ReturnType; + fieldDiffChangedCharacterMaximumLength: ReturnType< + typeof getFieldNewCharacterMaximumLength + >; + fieldDiffChangedScale: ReturnType; + fieldDiffChangedPrecision: ReturnType; + fieldDiffChangedPrimaryKey: ReturnType< + typeof getFieldNewPrimaryKey + >; isDiffFieldChanged: boolean; }>({ isDiffFieldRemoved: false, @@ -368,9 +372,9 @@ export const TableNodeField: React.FC = React.memo( > {fieldDiffChangedName ? ( <> - {field.name}{' '} + {fieldDiffChangedName.old}{' '} {' '} - {fieldDiffChangedName} + {fieldDiffChangedName.new} ) : ( field.name @@ -389,9 +393,8 @@ export const TableNodeField: React.FC = React.memo(
- {(field.primaryKey && - fieldDiffChangedPrimaryKey === null) || - fieldDiffChangedPrimaryKey ? ( + {(field.primaryKey && !fieldDiffChangedPrimaryKey?.old) || + fieldDiffChangedPrimaryKey?.new ? (
= React.memo( {fieldDiffChangedType ? ( <> - {field.type.name.split(' ')[0]} + { + fieldDiffChangedType.old.name.split( + ' ' + )[0] + } {' '} - {fieldDiffChangedType.name.split(' ')[0]} + { + fieldDiffChangedType.new.name.split( + ' ' + )[0] + } ) : ( `${field.type.name.split(' ')[0]}${ @@ -448,21 +459,21 @@ export const TableNodeField: React.FC = React.memo( ...field, ...{ precision: - fieldDiffChangedPrecision ?? + fieldDiffChangedPrecision?.new ?? field.precision, scale: - fieldDiffChangedScale ?? + fieldDiffChangedScale?.new ?? field.scale, characterMaximumLength: - fieldDiffChangedCharacterMaximumLength ?? + fieldDiffChangedCharacterMaximumLength?.new ?? field.characterMaximumLength, }, }) : '' }` )} - {fieldDiffChangedNullable !== null ? ( - fieldDiffChangedNullable ? ( + {fieldDiffChangedNullable ? ( + fieldDiffChangedNullable.new ? ( ? ) : ( ? diff --git a/src/pages/editor-page/canvas/table-node/table-node.tsx b/src/pages/editor-page/canvas/table-node/table-node.tsx index 5f0f8ea64..dd6582125 100644 --- a/src/pages/editor-page/canvas/table-node/table-node.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node.tsx @@ -138,7 +138,7 @@ export const TableNode: React.FC> = React.memo( ); const tableColor = useMemo(() => { if (tableChangedColor) { - return tableChangedColor; + return tableChangedColor.new; } return table.color; @@ -476,13 +476,13 @@ export const TableNode: React.FC> = React.memo( {tableChangedName ? ( ) : isDiffNewTable ? ( From 57b3b8777fd0a445abf0ba6603faab612d469d5c Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Sun, 28 Sep 2025 17:09:02 +0300 Subject: [PATCH 024/156] fix: add auto-increment field detection in smart-query import (#935) --- src/lib/data/import-metadata/import/fields.ts | 3 +++ .../metadata-types/column-info.ts | 2 ++ .../scripts/cockroachdb-script.ts | 8 +++++++- .../data/import-metadata/scripts/maria-script.ts | 7 +++++-- .../data/import-metadata/scripts/mysql-script.ts | 4 +++- .../import-metadata/scripts/postgres-script.ts | 7 ++++++- .../import-metadata/scripts/sqlite-script.ts | 16 ++++++++++++++-- .../import-metadata/scripts/sqlserver-script.ts | 5 +++++ 8 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/lib/data/import-metadata/import/fields.ts b/src/lib/data/import-metadata/import/fields.ts index 01c901c82..53d9830de 100644 --- a/src/lib/data/import-metadata/import/fields.ts +++ b/src/lib/data/import-metadata/import/fields.ts @@ -57,6 +57,9 @@ export const createFieldsFromMetadata = ({ ...(col.precision?.scale ? { scale: col.precision.scale } : {}), ...(col.default ? { default: col.default } : {}), ...(col.collation ? { collation: col.collation } : {}), + ...(col.is_identity !== undefined + ? { increment: col.is_identity } + : {}), createdAt: Date.now(), comments: col.comment ? col.comment : undefined, }) diff --git a/src/lib/data/import-metadata/metadata-types/column-info.ts b/src/lib/data/import-metadata/metadata-types/column-info.ts index 4e22a20f7..45eefb8b4 100644 --- a/src/lib/data/import-metadata/metadata-types/column-info.ts +++ b/src/lib/data/import-metadata/metadata-types/column-info.ts @@ -15,6 +15,7 @@ export interface ColumnInfo { default?: string | null; // Default value for the column, nullable collation?: string | null; comment?: string | null; + is_identity?: boolean; // Indicates if the column is auto-increment/identity } export const ColumnInfoSchema: z.ZodType = z.object({ @@ -35,4 +36,5 @@ export const ColumnInfoSchema: z.ZodType = z.object({ default: z.string().nullable().optional(), collation: z.string().nullable().optional(), comment: z.string().nullable().optional(), + is_identity: z.boolean().optional(), }); diff --git a/src/lib/data/import-metadata/scripts/cockroachdb-script.ts b/src/lib/data/import-metadata/scripts/cockroachdb-script.ts index a220dc6b9..9e8050dad 100644 --- a/src/lib/data/import-metadata/scripts/cockroachdb-script.ts +++ b/src/lib/data/import-metadata/scripts/cockroachdb-script.ts @@ -127,7 +127,13 @@ cols AS ( ',"default":"', null, '","collation":"', COALESCE(cols.COLLATION_NAME::TEXT, ''), '","comment":"', COALESCE(replace(replace(dsc.description::TEXT, '"', '\\"'), '\\x', '\\\\x'), ''), - '"}')), ',') AS cols_metadata + '","is_identity":', CASE + WHEN cols.is_identity = 'YES' THEN 'true' + WHEN cols.column_default IS NOT NULL AND cols.column_default LIKE 'nextval(%' THEN 'true' + WHEN cols.column_default LIKE 'unique_rowid()%' THEN 'true' + ELSE 'false' + END, + '}')), ',') AS cols_metadata FROM information_schema.columns cols LEFT JOIN pg_catalog.pg_class c ON c.relname = cols.table_name diff --git a/src/lib/data/import-metadata/scripts/maria-script.ts b/src/lib/data/import-metadata/scripts/maria-script.ts index 65067c971..8ad709665 100644 --- a/src/lib/data/import-metadata/scripts/maria-script.ts +++ b/src/lib/data/import-metadata/scripts/maria-script.ts @@ -69,7 +69,9 @@ SELECT CAST(CONCAT( ',"ordinal_position":', cols.ordinal_position, ',"nullable":', IF(cols.is_nullable = 'YES', 'true', 'false'), ',"default":"', ${withExtras ? withDefault : withoutDefault}, - '","collation":"', IFNULL(cols.collation_name, ''), '"}') + '","collation":"', IFNULL(cols.collation_name, ''), + '","is_identity":', IF(cols.extra LIKE '%auto_increment%', 'true', 'false'), + '"}') ) FROM ( SELECT cols.table_schema, cols.table_name, @@ -81,7 +83,8 @@ SELECT CAST(CONCAT( cols.ordinal_position, cols.is_nullable, cols.column_default, - cols.collation_name + cols.collation_name, + cols.extra FROM information_schema.columns cols WHERE cols.table_schema = DATABASE() ) AS cols), ''), diff --git a/src/lib/data/import-metadata/scripts/mysql-script.ts b/src/lib/data/import-metadata/scripts/mysql-script.ts index 1967f26ff..9839c8386 100644 --- a/src/lib/data/import-metadata/scripts/mysql-script.ts +++ b/src/lib/data/import-metadata/scripts/mysql-script.ts @@ -92,7 +92,9 @@ export const getMySQLQuery = ( ',"ordinal_position":', cols.ordinal_position, ',"nullable":', IF(cols.is_nullable = 'YES', 'true', 'false'), ',"default":"', ${withExtras ? withDefault : withoutDefault}, - '","collation":"', IFNULL(cols.collation_name, ''), '"}' + '","collation":"', IFNULL(cols.collation_name, ''), + '","is_identity":', IF(cols.extra LIKE '%auto_increment%', 'true', 'false'), + '}' ))))) ), indexes as ( (SELECT (@indexes:=NULL), diff --git a/src/lib/data/import-metadata/scripts/postgres-script.ts b/src/lib/data/import-metadata/scripts/postgres-script.ts index 0b0924d0b..25df18648 100644 --- a/src/lib/data/import-metadata/scripts/postgres-script.ts +++ b/src/lib/data/import-metadata/scripts/postgres-script.ts @@ -194,7 +194,12 @@ cols AS ( ',"default":"', ${withExtras ? withDefault : withoutDefault}, '","collation":"', COALESCE(cols.COLLATION_NAME, ''), '","comment":"', ${withExtras ? withComments : withoutComments}, - '"}')), ',') AS cols_metadata + '","is_identity":', CASE + WHEN cols.is_identity = 'YES' THEN 'true' + WHEN cols.column_default IS NOT NULL AND cols.column_default LIKE 'nextval(%' THEN 'true' + ELSE 'false' + END, + '}')), ',') AS cols_metadata FROM information_schema.columns cols LEFT JOIN pg_catalog.pg_class c ON c.relname = cols.table_name diff --git a/src/lib/data/import-metadata/scripts/sqlite-script.ts b/src/lib/data/import-metadata/scripts/sqlite-script.ts index 3ece3a73d..c1be6cfb4 100644 --- a/src/lib/data/import-metadata/scripts/sqlite-script.ts +++ b/src/lib/data/import-metadata/scripts/sqlite-script.ts @@ -119,7 +119,13 @@ WITH fk_info AS ( END ELSE null END, - 'default', ${withExtras ? withDefault : withoutDefault} + 'default', ${withExtras ? withDefault : withoutDefault}, + 'is_identity', + CASE + WHEN p.pk = 1 AND LOWER(p.type) LIKE '%int%' THEN json('true') + WHEN LOWER((SELECT sql FROM sqlite_master WHERE name = m.name)) LIKE '%' || p.name || '%autoincrement%' THEN json('true') + ELSE json('false') + END ) ) AS cols_metadata FROM @@ -292,7 +298,13 @@ WITH fk_info AS ( END ELSE null END, - 'default', ${withExtras ? withDefault : withoutDefault} + 'default', ${withExtras ? withDefault : withoutDefault}, + 'is_identity', + CASE + WHEN p.pk = 1 AND LOWER(p.type) LIKE '%int%' THEN json('true') + WHEN LOWER((SELECT sql FROM sqlite_master WHERE name = m.name)) LIKE '%' || p.name || '%autoincrement%' THEN json('true') + ELSE json('false') + END ) ) AS cols_metadata FROM diff --git a/src/lib/data/import-metadata/scripts/sqlserver-script.ts b/src/lib/data/import-metadata/scripts/sqlserver-script.ts index b6a6ee04f..d29ac0c27 100644 --- a/src/lib/data/import-metadata/scripts/sqlserver-script.ts +++ b/src/lib/data/import-metadata/scripts/sqlserver-script.ts @@ -91,6 +91,11 @@ cols AS ( WHEN cols.COLLATION_NAME IS NULL THEN 'null' ELSE '"' + STRING_ESCAPE(cols.COLLATION_NAME, 'json') + '"' END + + ', "is_identity": ' + CASE + WHEN COLUMNPROPERTY(OBJECT_ID(cols.TABLE_SCHEMA + '.' + cols.TABLE_NAME), cols.COLUMN_NAME, 'IsIdentity') = 1 + THEN 'true' + ELSE 'false' + END + N'}') COLLATE DATABASE_DEFAULT ), N',' ) + From c3c646bf7cbb1328f4b2eb85c9a7e929f0fcd3b9 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 28 Sep 2025 20:27:41 +0300 Subject: [PATCH 025/156] fix: add rels export dbml (#937) --- src/lib/dbml/dbml-export/dbml-export.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index e2d1e1bf4..4185bfa14 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -732,9 +732,17 @@ const restoreTableSchemas = (dbml: string, tables: DBTable[]): string => { return result; }; +// Function to extract only Ref statements from DBML +const extractRelationshipsDbml = (dbml: string): string => { + const lines = dbml.split('\n'); + const refLines = lines.filter((line) => line.trim().startsWith('Ref ')); + return refLines.join('\n').trim(); +}; + export interface DBMLExportResult { standardDbml: string; inlineDbml: string; + relationshipsDbml: string; error?: string; } @@ -923,5 +931,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { } } - return { standardDbml: standard, inlineDbml: inline, error: errorMsg }; + // Extract relationships DBML from standard output + const relationshipsDbml = extractRelationshipsDbml(standard); + + return { + standardDbml: standard, + inlineDbml: inline, + relationshipsDbml, + error: errorMsg, + }; } From b15bc945acb96d7cb3832b3b1b607dfcaef9e5ca Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Thu, 9 Oct 2025 15:05:33 +0300 Subject: [PATCH 026/156] fix: add timestampz and int as datatypes to postgres (#940) --- src/lib/data/data-types/postgres-data-types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/data/data-types/postgres-data-types.ts b/src/lib/data/data-types/postgres-data-types.ts index 0c85058f6..81a680619 100644 --- a/src/lib/data/data-types/postgres-data-types.ts +++ b/src/lib/data/data-types/postgres-data-types.ts @@ -12,6 +12,7 @@ export const postgresDataTypes: readonly DataTypeData[] = [ { name: 'text', id: 'text', usageLevel: 1 }, { name: 'boolean', id: 'boolean', usageLevel: 1 }, { name: 'timestamp', id: 'timestamp', usageLevel: 1 }, + { name: 'timestamptz', id: 'timestamptz', usageLevel: 1 }, { name: 'date', id: 'date', usageLevel: 1 }, // Level 2 - Second most common types @@ -42,6 +43,7 @@ export const postgresDataTypes: readonly DataTypeData[] = [ id: 'timestamp_with_time_zone', usageLevel: 2, }, + { name: 'int', id: 'int', usageLevel: 2 }, // Less common types { From bcd8aa9378aa563f40a2b6802cc503be4c882356 Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Thu, 9 Oct 2025 15:43:41 +0300 Subject: [PATCH 027/156] fix: auto-enter edit mode when creating new tables from canvas (#943) * fix: auto-enter edit mode when creating new tables from canvas or sidebar * fix * fix --------- Co-authored-by: Guy Ben-Aharon --- .../chartdb-context/chartdb-provider.tsx | 1 + .../canvas/canvas-context-menu.tsx | 39 ++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/context/chartdb-context/chartdb-provider.tsx b/src/context/chartdb-context/chartdb-provider.tsx index 58d46b388..8559539ce 100644 --- a/src/context/chartdb-context/chartdb-provider.tsx +++ b/src/context/chartdb-context/chartdb-provider.tsx @@ -350,6 +350,7 @@ export const ChartDBProvider: React.FC< isView: false, order: tables.length, ...attributes, + schema: attributes?.schema ?? defaultSchemas[databaseType], }; table.indexes = getTableIndexesWithPrimaryKey({ diff --git a/src/pages/editor-page/canvas/canvas-context-menu.tsx b/src/pages/editor-page/canvas/canvas-context-menu.tsx index cbf0327c9..3dd17548f 100644 --- a/src/pages/editor-page/canvas/canvas-context-menu.tsx +++ b/src/pages/editor-page/canvas/canvas-context-menu.tsx @@ -13,6 +13,8 @@ import { useTranslation } from 'react-i18next'; import { Table, Workflow, Group, View } from 'lucide-react'; import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter'; import { useLocalConfig } from '@/hooks/use-local-config'; +import { useCanvas } from '@/hooks/use-canvas'; +import type { DBTable } from '@/lib/domain'; export const CanvasContextMenu: React.FC = ({ children, @@ -23,24 +25,28 @@ export const CanvasContextMenu: React.FC = ({ const { screenToFlowPosition } = useReactFlow(); const { t } = useTranslation(); const { showDBViews } = useLocalConfig(); + const { setEditTableModeTable } = useCanvas(); const { isMd: isDesktop } = useBreakpoint('md'); const createTableHandler = useCallback( - (event: React.MouseEvent) => { + async (event: React.MouseEvent) => { const position = screenToFlowPosition({ x: event.clientX, y: event.clientY, }); + let newTable: DBTable | null = null; + if (schemasDisplayed.length > 1) { openTableSchemaDialog({ - onConfirm: ({ schema }) => - createTable({ + onConfirm: async ({ schema }) => { + newTable = await createTable({ x: position.x, y: position.y, schema: schema.name, - }), + }); + }, schemas: schemasDisplayed, }); } else { @@ -48,37 +54,45 @@ export const CanvasContextMenu: React.FC = ({ schemasDisplayed?.length === 1 ? schemasDisplayed[0]?.name : undefined; - createTable({ + newTable = await createTable({ x: position.x, y: position.y, schema, }); } + + if (newTable) { + setEditTableModeTable({ tableId: newTable.id }); + } }, [ createTable, screenToFlowPosition, openTableSchemaDialog, schemasDisplayed, + setEditTableModeTable, ] ); const createViewHandler = useCallback( - (event: React.MouseEvent) => { + async (event: React.MouseEvent) => { const position = screenToFlowPosition({ x: event.clientX, y: event.clientY, }); + let newView: DBTable | null = null; + if (schemasDisplayed.length > 1) { openTableSchemaDialog({ - onConfirm: ({ schema }) => - createTable({ + onConfirm: async ({ schema }) => { + newView = await createTable({ x: position.x, y: position.y, schema: schema.name, isView: true, - }), + }); + }, schemas: schemasDisplayed, }); } else { @@ -86,19 +100,24 @@ export const CanvasContextMenu: React.FC = ({ schemasDisplayed?.length === 1 ? schemasDisplayed[0]?.name : undefined; - createTable({ + newView = await createTable({ x: position.x, y: position.y, schema, isView: true, }); } + + if (newView) { + setEditTableModeTable({ tableId: newView.id }); + } }, [ createTable, screenToFlowPosition, openTableSchemaDialog, schemasDisplayed, + setEditTableModeTable, ] ); From 498655e7b77e57eaf641ba86263ce1ef60b93e16 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Fri, 10 Oct 2025 15:22:09 +0300 Subject: [PATCH 028/156] fix: prevent text input glitch when editing table field names (#944) --- src/hooks/use-update-table-field.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/hooks/use-update-table-field.ts b/src/hooks/use-update-table-field.ts index f1282b29f..001844c62 100644 --- a/src/hooks/use-update-table-field.ts +++ b/src/hooks/use-update-table-field.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useEffect } from 'react'; +import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; import { useChartDB } from './use-chartdb'; import { useDebounce } from './use-debounce-v2'; import type { DBField, DBTable } from '@/lib/domain'; @@ -75,12 +75,20 @@ export const useUpdateTableField = ( const [localNullable, setLocalNullable] = useState(field.nullable); const [localPrimaryKey, setLocalPrimaryKey] = useState(field.primaryKey); + const lastFieldNameRef = useRef(field.name); + + useEffect(() => { + if (localFieldName === lastFieldNameRef.current) { + lastFieldNameRef.current = field.name; + setLocalFieldName(field.name); + } + }, [field.name, localFieldName]); + // Update local state when field properties change externally useEffect(() => { - setLocalFieldName(field.name); setLocalNullable(field.nullable); setLocalPrimaryKey(field.primaryKey); - }, [field.name, field.nullable, field.primaryKey]); + }, [field.nullable, field.primaryKey]); // Use custom updateField if provided, otherwise use the chartDB one const updateField = useMemo( From 38fedcec0c10ea2b3f0b7fc92ca1f5ac9e540389 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Fri, 10 Oct 2025 19:38:43 +0300 Subject: [PATCH 029/156] fix: exit table edit on area click (#945) * fix: exit table edit on area click * fix --- src/pages/editor-page/canvas/area-node/area-node.tsx | 4 ++++ src/pages/editor-page/canvas/canvas.tsx | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/pages/editor-page/canvas/area-node/area-node.tsx b/src/pages/editor-page/canvas/area-node/area-node.tsx index 4d3dd1865..5ace33a17 100644 --- a/src/pages/editor-page/canvas/area-node/area-node.tsx +++ b/src/pages/editor-page/canvas/area-node/area-node.tsx @@ -16,6 +16,7 @@ import { Check, GripVertical, Pencil } from 'lucide-react'; import { Button } from '@/components/button/button'; import { useLayout } from '@/hooks/use-layout'; import { AreaNodeContextMenu } from './area-node-context-menu'; +import { useCanvas } from '@/hooks/use-canvas'; export type AreaNodeType = Node< { @@ -57,6 +58,8 @@ export const AreaNode: React.FC> = React.memo( useKeyPressEvent('Enter', editAreaName); useKeyPressEvent('Escape', abortEdit); + const { setEditTableModeTable } = useCanvas(); + const enterEditMode = (e: React.MouseEvent) => { e.stopPropagation(); setEditMode(true); @@ -77,6 +80,7 @@ export const AreaNode: React.FC> = React.memo( borderColor: selected ? undefined : area.color, }} onClick={(e) => { + setEditTableModeTable(null); if (e.detail === 2) { openAreaInEditor(); } diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index 8a396e0b5..b9c1187fd 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -200,6 +200,9 @@ const areaToAreaNode = ( width: area.width, height: area.height, zIndex: -10, + style: { + zIndex: -10, + }, hidden: !hasVisibleTable || filterLoading, }; }; From 34475add32f11323589ef092ccf2a8e9152ff272 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Mon, 13 Oct 2025 18:08:28 +0300 Subject: [PATCH 030/156] feat: create relationships on canvas modal (#946) * feat: create relationships on canvas modal * feat: create relationships on canvas modal * feat: create relationships on canvas modal * fix * fix * fix * fix --- src/components/select-box/select-box.tsx | 8 +- src/context/canvas-context/canvas-context.tsx | 33 ++ .../canvas-context/canvas-provider.tsx | 81 ++++- src/pages/editor-page/canvas/canvas.tsx | 312 ++++++++++++++--- .../connection-line/connection-line.tsx | 38 +++ .../create-relationship-node.tsx | 321 ++++++++++++++++++ .../table-node/table-node-context-menu.tsx | 68 ++-- .../canvas/table-node/table-node.tsx | 78 ++++- .../temp-cursor-node/temp-cursor-node.tsx | 36 ++ .../temp-floating-edge/temp-floating-edge.tsx | 64 ++++ 10 files changed, 951 insertions(+), 88 deletions(-) create mode 100644 src/pages/editor-page/canvas/connection-line/connection-line.tsx create mode 100644 src/pages/editor-page/canvas/create-relationship-node/create-relationship-node.tsx create mode 100644 src/pages/editor-page/canvas/temp-cursor-node/temp-cursor-node.tsx create mode 100644 src/pages/editor-page/canvas/temp-floating-edge/temp-floating-edge.tsx diff --git a/src/components/select-box/select-box.tsx b/src/components/select-box/select-box.tsx index 9f14adf2f..496bff8cc 100644 --- a/src/components/select-box/select-box.tsx +++ b/src/components/select-box/select-box.tsx @@ -58,6 +58,7 @@ export interface SelectBoxProps { footerButtons?: React.ReactNode; commandOnMouseDown?: (e: React.MouseEvent) => void; commandOnClick?: (e: React.MouseEvent) => void; + onSearchChange?: (search: string) => void; } export const SelectBox = React.forwardRef( @@ -87,6 +88,7 @@ export const SelectBox = React.forwardRef( footerButtons, commandOnMouseDown, commandOnClick, + onSearchChange, }, ref ) => { @@ -240,6 +242,7 @@ export const SelectBox = React.forwardRef( handleSelect( @@ -404,7 +407,10 @@ export const SelectBox = React.forwardRef(
setSearchTerm(e)} + onValueChange={(e) => { + setSearchTerm(e); + onSearchChange?.(e); + }} ref={ref} placeholder={inputPlaceholder ?? 'Search...'} className="h-9" diff --git a/src/context/canvas-context/canvas-context.tsx b/src/context/canvas-context/canvas-context.tsx index 7ec899aa5..f9b76ed02 100644 --- a/src/context/canvas-context/canvas-context.tsx +++ b/src/context/canvas-context/canvas-context.tsx @@ -24,6 +24,31 @@ export interface CanvasContext { fieldId?: string; } | null> >; + tempFloatingEdge: { + sourceNodeId: string; + targetNodeId?: string; + } | null; + setTempFloatingEdge: React.Dispatch< + React.SetStateAction<{ + sourceNodeId: string; + targetNodeId?: string; + } | null> + >; + startFloatingEdgeCreation: ({ + sourceNodeId, + }: { + sourceNodeId: string; + }) => void; + endFloatingEdgeCreation: () => void; + hoveringTableId: string | null; + setHoveringTableId: React.Dispatch>; + showCreateRelationshipNode: (params: { + sourceTableId: string; + targetTableId: string; + x: number; + y: number; + }) => void; + hideCreateRelationshipNode: () => void; } export const canvasContext = createContext({ @@ -35,4 +60,12 @@ export const canvasContext = createContext({ showFilter: false, editTableModeTable: null, setEditTableModeTable: emptyFn, + tempFloatingEdge: null, + setTempFloatingEdge: emptyFn, + startFloatingEdgeCreation: emptyFn, + endFloatingEdgeCreation: emptyFn, + hoveringTableId: null, + setHoveringTableId: emptyFn, + showCreateRelationshipNode: emptyFn, + hideCreateRelationshipNode: emptyFn, }); diff --git a/src/context/canvas-context/canvas-provider.tsx b/src/context/canvas-context/canvas-provider.tsx index 62cae45b0..c0ed8c03a 100644 --- a/src/context/canvas-context/canvas-provider.tsx +++ b/src/context/canvas-context/canvas-provider.tsx @@ -5,6 +5,7 @@ import React, { useEffect, useRef, } from 'react'; +import type { CanvasContext } from './canvas-context'; import { canvasContext } from './canvas-context'; import { useChartDB } from '@/hooks/use-chartdb'; import { adjustTablePositions } from '@/lib/domain/db-table'; @@ -15,6 +16,10 @@ import { createGraph } from '@/lib/graph'; import { useDiagramFilter } from '../diagram-filter-context/use-diagram-filter'; import { filterTable } from '@/lib/domain/diagram-filter/filter'; import { defaultSchemas } from '@/lib/data/default-schemas'; +import { + CREATE_RELATIONSHIP_NODE_ID, + type CreateRelationshipNodeType, +} from '@/pages/editor-page/canvas/create-relationship-node/create-relationship-node'; interface CanvasProviderProps { children: ReactNode; @@ -30,7 +35,7 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => { diagramId, } = useChartDB(); const { filter, loading: filterLoading } = useDiagramFilter(); - const { fitView } = useReactFlow(); + const { fitView, screenToFlowPosition, setNodes } = useReactFlow(); const [overlapGraph, setOverlapGraph] = useState>(createGraph()); const [editTableModeTable, setEditTableModeTable] = useState<{ @@ -39,6 +44,12 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => { } | null>(null); const [showFilter, setShowFilter] = useState(false); + + const [tempFloatingEdge, setTempFloatingEdge] = + useState(null); + + const [hoveringTableId, setHoveringTableId] = useState(null); + const diagramIdActiveFilterRef = useRef(); useEffect(() => { @@ -122,6 +133,66 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => { ] ); + const startFloatingEdgeCreation: CanvasContext['startFloatingEdgeCreation'] = + useCallback(({ sourceNodeId }) => { + setShowFilter(false); + setTempFloatingEdge({ + sourceNodeId, + }); + }, []); + + const endFloatingEdgeCreation: CanvasContext['endFloatingEdgeCreation'] = + useCallback(() => { + setTempFloatingEdge(null); + }, []); + + const hideCreateRelationshipNode: CanvasContext['hideCreateRelationshipNode'] = + useCallback(() => { + setNodes((nds) => + nds.filter((n) => n.id !== CREATE_RELATIONSHIP_NODE_ID) + ); + endFloatingEdgeCreation(); + }, [setNodes, endFloatingEdgeCreation]); + + const showCreateRelationshipNode: CanvasContext['showCreateRelationshipNode'] = + useCallback( + ({ sourceTableId, targetTableId, x, y }) => { + setTempFloatingEdge((edge) => + edge + ? { + ...edge, + targetNodeId: targetTableId, + } + : null + ); + const cursorPos = screenToFlowPosition({ + x, + y, + }); + + const newNode: CreateRelationshipNodeType = { + id: CREATE_RELATIONSHIP_NODE_ID, + type: 'create-relationship', + position: cursorPos, + data: { + sourceTableId, + targetTableId, + }, + draggable: true, + selectable: false, + zIndex: 1000, + }; + + setNodes((nds) => { + const nodesWithoutOldCreateRelationshipNode = nds.filter( + (n) => n.id !== CREATE_RELATIONSHIP_NODE_ID + ); + return [...nodesWithoutOldCreateRelationshipNode, newNode]; + }); + }, + [screenToFlowPosition, setNodes] + ); + return ( { showFilter, editTableModeTable, setEditTableModeTable, + tempFloatingEdge: tempFloatingEdge, + setTempFloatingEdge: setTempFloatingEdge, + startFloatingEdgeCreation: startFloatingEdgeCreation, + endFloatingEdgeCreation: endFloatingEdgeCreation, + hoveringTableId, + setHoveringTableId, + showCreateRelationshipNode, + hideCreateRelationshipNode, }} > {children} diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index b9c1187fd..bf0afe293 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -30,7 +30,11 @@ import { import '@xyflow/react/dist/style.css'; import equal from 'fast-deep-equal'; import type { TableNodeType } from './table-node/table-node'; -import { TableNode } from './table-node/table-node'; +import { + TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX, + TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX, + TableNode, +} from './table-node/table-node'; import type { RelationshipEdgeType } from './relationship-edge/relationship-edge'; import { RelationshipEdge } from './relationship-edge/relationship-edge'; import { useChartDB } from '@/hooks/use-chartdb'; @@ -79,6 +83,20 @@ import { useCanvas } from '@/hooks/use-canvas'; import type { AreaNodeType } from './area-node/area-node'; import { AreaNode } from './area-node/area-node'; import type { Area } from '@/lib/domain/area'; +import type { TempCursorNodeType } from './temp-cursor-node/temp-cursor-node'; +import { + TEMP_CURSOR_HANDLE_ID, + TEMP_CURSOR_NODE_ID, + TempCursorNode, +} from './temp-cursor-node/temp-cursor-node'; +import type { TempFloatingEdgeType } from './temp-floating-edge/temp-floating-edge'; +import { + TEMP_FLOATING_EDGE_ID, + TempFloatingEdge, +} from './temp-floating-edge/temp-floating-edge'; +import type { CreateRelationshipNodeType } from './create-relationship-node/create-relationship-node'; +import { CreateRelationshipNode } from './create-relationship-node/create-relationship-node'; +import { ConnectionLine } from './connection-line/connection-line'; import { updateTablesParentAreas, getTablesInArea, @@ -97,20 +115,30 @@ import { useClickAway } from 'react-use'; const HIGHLIGHTED_EDGE_Z_INDEX = 1; const DEFAULT_EDGE_Z_INDEX = 0; -export type EdgeType = RelationshipEdgeType | DependencyEdgeType; +export type EdgeType = + | RelationshipEdgeType + | DependencyEdgeType + | TempFloatingEdgeType; -export type NodeType = TableNodeType | AreaNodeType; +export type NodeType = + | TableNodeType + | AreaNodeType + | TempCursorNodeType + | CreateRelationshipNodeType; type AddEdgeParams = Parameters>[0]; const edgeTypes: EdgeTypes = { 'relationship-edge': RelationshipEdge, 'dependency-edge': DependencyEdge, + 'temp-floating-edge': TempFloatingEdge, }; const nodeTypes: NodeTypes = { table: TableNode, area: AreaNode, + 'temp-cursor': TempCursorNode, + 'create-relationship': CreateRelationshipNode, }; const initialEdges: EdgeType[] = []; @@ -123,12 +151,14 @@ const tableToTableNode = ( filterLoading, showDBViews, forceShow, + isRelationshipCreatingTarget = false, }: { filter?: DiagramFilter; databaseType: DatabaseType; filterLoading: boolean; showDBViews?: boolean; forceShow?: boolean; + isRelationshipCreatingTarget?: boolean; } ): TableNodeType => { // Always use absolute position for now @@ -156,6 +186,7 @@ const tableToTableNode = ( data: { table, isOverlapping: false, + isRelationshipCreatingTarget, }, width: table.width ?? MIN_TABLE_SIZE, hidden, @@ -252,6 +283,10 @@ export const Canvas: React.FC = ({ initialTables }) => { showFilter, setShowFilter, setEditTableModeTable, + tempFloatingEdge, + endFloatingEdgeCreation, + hoveringTableId, + hideCreateRelationshipNode, } = useCanvas(); const { filter, loading: filterLoading } = useDiagramFilter(); const { checkIfNewTable } = useDiff(); @@ -273,6 +308,7 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, showDBViews, forceShow: shouldForceShowTable(table.id), + isRelationshipCreatingTarget: false, }) ) ); @@ -281,6 +317,11 @@ export const Canvas: React.FC = ({ initialTables }) => { const [snapToGridEnabled, setSnapToGridEnabled] = useState(false); + const [cursorPosition, setCursorPosition] = useState<{ + x: number; + y: number; + } | null>(null); + useEffect(() => { setIsInitialLoadingNodes(true); }, [initialTables]); @@ -293,6 +334,7 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, showDBViews, forceShow: shouldForceShowTable(table.id), + isRelationshipCreatingTarget: false, }) ); if (equal(initialNodes, nodes)) { @@ -398,58 +440,62 @@ export const Canvas: React.FC = ({ initialTables }) => { // Check if any edge needs updating let hasChanges = false; - const newEdges = prevEdges.map((edge): EdgeType => { - const shouldBeHighlighted = - selectedRelationshipIdsSet.has(edge.id) || - selectedTableIdsSet.has(edge.source) || - selectedTableIdsSet.has(edge.target); - - const currentHighlighted = edge.data?.highlighted ?? false; - const currentAnimated = edge.animated ?? false; - const currentZIndex = edge.zIndex ?? 0; - - // Skip if no changes needed - if ( - currentHighlighted === shouldBeHighlighted && - currentAnimated === shouldBeHighlighted && - currentZIndex === - (shouldBeHighlighted - ? HIGHLIGHTED_EDGE_Z_INDEX - : DEFAULT_EDGE_Z_INDEX) - ) { - return edge; - } + const newEdges = prevEdges + .filter((e) => e.type !== 'temp-floating-edge') + .map((edge): EdgeType => { + const shouldBeHighlighted = + selectedRelationshipIdsSet.has(edge.id) || + selectedTableIdsSet.has(edge.source) || + selectedTableIdsSet.has(edge.target); + + const currentHighlighted = + (edge as Exclude).data + ?.highlighted ?? false; + const currentAnimated = edge.animated ?? false; + const currentZIndex = edge.zIndex ?? 0; + + // Skip if no changes needed + if ( + currentHighlighted === shouldBeHighlighted && + currentAnimated === shouldBeHighlighted && + currentZIndex === + (shouldBeHighlighted + ? HIGHLIGHTED_EDGE_Z_INDEX + : DEFAULT_EDGE_Z_INDEX) + ) { + return edge; + } - hasChanges = true; + hasChanges = true; - if (edge.type === 'dependency-edge') { - const dependencyEdge = edge as DependencyEdgeType; - return { - ...dependencyEdge, - data: { - ...dependencyEdge.data!, - highlighted: shouldBeHighlighted, - }, - animated: shouldBeHighlighted, - zIndex: shouldBeHighlighted - ? HIGHLIGHTED_EDGE_Z_INDEX - : DEFAULT_EDGE_Z_INDEX, - }; - } else { - const relationshipEdge = edge as RelationshipEdgeType; - return { - ...relationshipEdge, - data: { - ...relationshipEdge.data!, - highlighted: shouldBeHighlighted, - }, - animated: shouldBeHighlighted, - zIndex: shouldBeHighlighted - ? HIGHLIGHTED_EDGE_Z_INDEX - : DEFAULT_EDGE_Z_INDEX, - }; - } - }); + if (edge.type === 'dependency-edge') { + const dependencyEdge = edge as DependencyEdgeType; + return { + ...dependencyEdge, + data: { + ...dependencyEdge.data!, + highlighted: shouldBeHighlighted, + }, + animated: shouldBeHighlighted, + zIndex: shouldBeHighlighted + ? HIGHLIGHTED_EDGE_Z_INDEX + : DEFAULT_EDGE_Z_INDEX, + }; + } else { + const relationshipEdge = edge as RelationshipEdgeType; + return { + ...relationshipEdge, + data: { + ...relationshipEdge.data!, + highlighted: shouldBeHighlighted, + }, + animated: shouldBeHighlighted, + zIndex: shouldBeHighlighted + ? HIGHLIGHTED_EDGE_Z_INDEX + : DEFAULT_EDGE_Z_INDEX, + }; + } + }); return hasChanges ? newEdges : prevEdges; }); @@ -467,6 +513,7 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, showDBViews, forceShow: shouldForceShowTable(table.id), + isRelationshipCreatingTarget: false, }); // Check if table uses the highlighted custom type @@ -496,6 +543,11 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, }) ), + ...prevNodes.filter( + (n) => + n.type === 'temp-cursor' || + n.type === 'create-relationship' + ), ]; // Check if nodes actually changed @@ -520,6 +572,37 @@ export const Canvas: React.FC = ({ initialTables }) => { shouldForceShowTable, ]); + // Surgical update for relationship creation target highlighting + // This avoids expensive full node recalculation when only the visual state changes + useEffect(() => { + setNodes((nds) => { + let hasChanges = false; + const updatedNodes = nds.map((node) => { + if (node.type !== 'table') return node; + + const shouldBeTarget = + !!tempFloatingEdge?.sourceNodeId && + node.id !== tempFloatingEdge.sourceNodeId; + const isCurrentlyTarget = + node.data.isRelationshipCreatingTarget ?? false; + + if (shouldBeTarget !== isCurrentlyTarget) { + hasChanges = true; + return { + ...node, + data: { + ...node.data, + isRelationshipCreatingTarget: shouldBeTarget, + }, + }; + } + return node; + }); + + return hasChanges ? updatedNodes : nds; + }); + }, [tempFloatingEdge?.sourceNodeId, setNodes]); + const prevFilter = useRef(undefined); useEffect(() => { if (!equal(filter, prevFilter.current)) { @@ -1227,6 +1310,7 @@ export const Canvas: React.FC = ({ initialTables }) => { [setEditTableModeTable] ); useClickAway(containerRef, exitEditTableMode); + useClickAway(containerRef, hideCreateRelationshipNode); const shiftPressed = useKeyPress('Shift'); const operatingSystem = getOperatingSystem(); @@ -1243,19 +1327,134 @@ export const Canvas: React.FC = ({ initialTables }) => { [] ); + // Handle mouse move to update cursor position for floating edge + const { screenToFlowPosition } = useReactFlow(); + const rafIdRef = useRef(); + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + if (tempFloatingEdge) { + // Throttle using requestAnimationFrame + if (rafIdRef.current) { + return; + } + + rafIdRef.current = requestAnimationFrame(() => { + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + setCursorPosition(position); + rafIdRef.current = undefined; + }); + } + }, + [tempFloatingEdge, screenToFlowPosition] + ); + + // Cleanup RAF on unmount + useEffect(() => { + return () => { + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current); + } + }; + }, []); + + // Handle escape key to cancel floating edge creation and close relationship node + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (tempFloatingEdge) { + endFloatingEdgeCreation(); + setCursorPosition(null); + } + // Also close CreateRelationshipNode if present + hideCreateRelationshipNode(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [tempFloatingEdge, endFloatingEdgeCreation, hideCreateRelationshipNode]); + + // Add temporary invisible node at cursor position and edge + const nodesWithCursor = useMemo(() => { + if (!tempFloatingEdge || !cursorPosition) { + return nodes; + } + + const tempNode: TempCursorNodeType = { + id: TEMP_CURSOR_NODE_ID, + type: 'temp-cursor', + position: cursorPosition, + data: {}, + draggable: false, + selectable: false, + }; + + return [...nodes, tempNode]; + }, [nodes, tempFloatingEdge, cursorPosition]); + + const edgesWithFloating = useMemo(() => { + if (!tempFloatingEdge || !cursorPosition) return edges; + + let target = TEMP_CURSOR_NODE_ID; + let targetHandle: string | undefined = TEMP_CURSOR_HANDLE_ID; + + if (tempFloatingEdge.targetNodeId) { + target = tempFloatingEdge.targetNodeId; + targetHandle = `${TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX}${tempFloatingEdge.targetNodeId}`; + } else if ( + hoveringTableId && + hoveringTableId !== tempFloatingEdge.sourceNodeId + ) { + target = hoveringTableId; + targetHandle = `${TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX}${hoveringTableId}`; + } + + const tempEdge: TempFloatingEdgeType = { + id: TEMP_FLOATING_EDGE_ID, + source: tempFloatingEdge.sourceNodeId, + sourceHandle: `${TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX}${tempFloatingEdge.sourceNodeId}`, + target, + targetHandle, + type: 'temp-floating-edge', + }; + + return [...edges, tempEdge]; + }, [edges, tempFloatingEdge, cursorPosition, hoveringTableId]); + + const onPaneClickHandler = useCallback(() => { + if (tempFloatingEdge) { + endFloatingEdgeCreation(); + setCursorPosition(null); + } + + // Close CreateRelationshipNode if it exists + hideCreateRelationshipNode(); + + // Exit edit table mode + exitEditTableMode(); + }, [ + tempFloatingEdge, + exitEditTableMode, + endFloatingEdgeCreation, + hideCreateRelationshipNode, + ]); + return (
= ({ initialTables }) => { panOnScroll={scrollAction === 'pan'} snapToGrid={shiftPressed || snapToGridEnabled} snapGrid={[20, 20]} - onPaneClick={exitEditTableMode} + onPaneClick={onPaneClickHandler} + connectionLineComponent={ConnectionLine} > +> = ({ fromX, fromY, toX, toY, fromPosition, toPosition }) => { + const [edgePath] = getSmoothStepPath({ + sourceX: fromX, + sourceY: fromY, + sourcePosition: fromPosition ?? Position.Right, + targetX: toX, + targetY: toY, + targetPosition: toPosition ?? Position.Left, + borderRadius: 14, + }); + + return ( + + + + + ); +}; diff --git a/src/pages/editor-page/canvas/create-relationship-node/create-relationship-node.tsx b/src/pages/editor-page/canvas/create-relationship-node/create-relationship-node.tsx new file mode 100644 index 000000000..5a6b31297 --- /dev/null +++ b/src/pages/editor-page/canvas/create-relationship-node/create-relationship-node.tsx @@ -0,0 +1,321 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { NodeProps, Node } from '@xyflow/react'; +import { Button } from '@/components/button/button'; +import { useChartDB } from '@/hooks/use-chartdb'; +import type { SelectBoxOption } from '@/components/select-box/select-box'; +import { SelectBox } from '@/components/select-box/select-box'; +import { areFieldTypesCompatible } from '@/lib/data/data-types/data-types'; +import { useLayout } from '@/hooks/use-layout'; +import { ArrowRight, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { generateId } from '@/lib/utils'; +import type { DBField } from '@/lib/domain/db-field'; +import { useReactFlow } from '@xyflow/react'; +import { useCanvas } from '@/hooks/use-canvas'; + +export const CREATE_RELATIONSHIP_NODE_ID = '__create-relationship-node__'; + +const CREATE_NEW_FIELD_VALUE = 'CREATE_NEW'; + +export type CreateRelationshipNodeType = Node< + { + sourceTableId: string; + targetTableId: string; + }, + 'create-relationship' +>; + +export const CreateRelationshipNode: React.FC< + NodeProps +> = React.memo(({ data }) => { + const { sourceTableId, targetTableId } = data; + const { getTable, createRelationship, databaseType, addField } = + useChartDB(); + const { hideCreateRelationshipNode } = useCanvas(); + const { setEdges } = useReactFlow(); + const { openRelationshipFromSidebar } = useLayout(); + const [targetFieldId, setTargetFieldId] = useState(); + const [errorMessage, setErrorMessage] = useState(''); + const [isVisible, setIsVisible] = useState(false); + const [selectOpen, setSelectOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + const sourceTable = useMemo( + () => getTable(sourceTableId), + [sourceTableId, getTable] + ); + const targetTable = useMemo( + () => getTable(targetTableId), + [targetTableId, getTable] + ); + + // Get the PK field from source table + const sourcePKField = useMemo(() => { + if (!sourceTable) return null; + return ( + sourceTable.fields.find((f) => f.primaryKey) || + sourceTable.fields[0] + ); + }, [sourceTable]); + + // Get compatible target fields (FK columns) + // Reset state when source or target table changes + useEffect(() => { + setTargetFieldId(undefined); + setSearchTerm(''); + setErrorMessage(''); + setSelectOpen(true); + }, [sourceTableId, targetTableId]); + + const targetFieldOptions = useMemo(() => { + if (!targetTable || !sourcePKField) return []; + + const compatibleFields = targetTable.fields + .filter((field) => + areFieldTypesCompatible( + sourcePKField.type, + field.type, + databaseType + ) + ) + .map( + (field) => + ({ + label: field.name, + value: field.id, + description: `(${field.type.name})`, + }) as SelectBoxOption + ); + + // Add option to create a new field if user typed a custom name + if ( + searchTerm && + !compatibleFields.find( + (f) => f.label.toLowerCase() === searchTerm.toLowerCase() + ) + ) { + compatibleFields.push({ + label: `Create "${searchTerm}"`, + value: CREATE_NEW_FIELD_VALUE, + description: `(${sourcePKField.type.name})`, + }); + } + + return compatibleFields; + }, [targetTable, sourcePKField, databaseType, searchTerm]); + + // Auto-select first compatible field OR pre-populate suggested name + useEffect(() => { + if (targetFieldOptions.length > 0 && !targetFieldId) { + setTargetFieldId(targetFieldOptions[0].value as string); + } else if ( + targetFieldOptions.length === 0 && + !searchTerm && + sourceTable && + sourcePKField + ) { + // No compatible fields - suggest a field name based on source table + PK field + const suggestedName = + sourcePKField.name.toLowerCase() === 'id' + ? `${sourceTable.name}_${sourcePKField.name}` + : sourcePKField.name; + setSearchTerm(suggestedName); + } + }, [ + targetFieldOptions.length, + sourceTable, + sourcePKField, + searchTerm, + targetFieldId, + targetFieldOptions, + ]); + + // Auto-open the select immediately and trigger animation + useEffect(() => { + setSelectOpen(true); + const rafId = requestAnimationFrame(() => { + setIsVisible(true); + }); + return () => cancelAnimationFrame(rafId); + }, []); + + const handleCreate = useCallback(async () => { + if (!sourcePKField) return; + + try { + let finalTargetFieldId = targetFieldId; + + // If user selected "CREATE_NEW", create the field first + if (targetFieldId === CREATE_NEW_FIELD_VALUE && searchTerm) { + const newField: DBField = { + id: generateId(), + name: searchTerm, + type: sourcePKField.type, + unique: false, + nullable: true, + primaryKey: false, + createdAt: Date.now(), + }; + + try { + await addField(targetTableId, newField); + finalTargetFieldId = newField.id; + } catch (fieldError) { + console.error('Failed to create field:', fieldError); + setErrorMessage('Failed to create new field'); + return; + } + } + + if (!finalTargetFieldId) { + setErrorMessage('Please select a target field'); + return; + } + + const relationship = await createRelationship({ + sourceTableId, + sourceFieldId: sourcePKField.id, + targetTableId, + targetFieldId: finalTargetFieldId, + }); + + setEdges((edges) => + edges.map((edge) => + edge.id === relationship.id + ? { ...edge, selected: true } + : { ...edge, selected: false } + ) + ); + + openRelationshipFromSidebar(relationship.id); + hideCreateRelationshipNode(); + } catch (error) { + console.error(error); + setErrorMessage('Failed to create relationship'); + } + }, [ + sourcePKField, + targetFieldId, + searchTerm, + sourceTableId, + targetTableId, + createRelationship, + addField, + setEdges, + openRelationshipFromSidebar, + hideCreateRelationshipNode, + ]); + + // Note: Escape key handling is done in canvas.tsx to avoid duplicate listeners + + if (!sourceTable || !targetTable || !sourcePKField) { + return null; + } + + return ( +
e.stopPropagation()} + > + {/* Header - draggable */} +
+
+ Create Relationship +
+ +
+ + {/* Content */} +
+
+ {/* PK Column (Source) */} +
+ +
+ {sourcePKField.name} +
+
+ {sourceTable.name} +
+
+ + {/* Arrow indicator */} +
+ +
+ + {/* FK Column (Target) */} +
+ + { + setTargetFieldId(value as string); + }} + emptyPlaceholder="No compatible fields" + onSearchChange={setSearchTerm} + open={selectOpen} + onOpenChange={setSelectOpen} + /> +
+ {targetTable.name} +
+
+
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {targetFieldOptions.length === 0 && ( +
+ No compatible fields found in target table +
+ )} +
+ + {/* Footer */} +
+ +
+
+ ); +}); + +CreateRelationshipNode.displayName = 'CreateRelationshipNode'; diff --git a/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx b/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx index 3343b06f9..b89efef2b 100644 --- a/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx @@ -12,7 +12,6 @@ import type { DBTable } from '@/lib/domain/db-table'; import { Copy, Pencil, Trash2, Workflow } from 'lucide-react'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useDialog } from '@/hooks/use-dialog'; import { useCanvas } from '@/hooks/use-canvas'; export interface TableNodeContextMenuProps { @@ -26,37 +25,56 @@ export const TableNodeContextMenu: React.FC< const { closeAllTablesInSidebar } = useLayout(); const { t } = useTranslation(); const { isMd: isDesktop } = useBreakpoint('md'); - const { openCreateRelationshipDialog } = useDialog(); - const { setEditTableModeTable } = useCanvas(); + const { setEditTableModeTable, startFloatingEdgeCreation } = useCanvas(); - const duplicateTableHandler = useCallback(() => { - const clonedTable = cloneTable(table); + const duplicateTableHandler: React.MouseEventHandler = + useCallback( + (e) => { + e.stopPropagation(); + const clonedTable = cloneTable(table); - clonedTable.name = `${clonedTable.name}_copy`; - clonedTable.x += 30; - clonedTable.y += 50; + clonedTable.name = `${clonedTable.name}_copy`; + clonedTable.x += 30; + clonedTable.y += 50; - createTable(clonedTable); - }, [createTable, table]); + createTable(clonedTable); + }, + [createTable, table] + ); - const editTableHandler = useCallback(() => { - if (readonly) { - return; - } + const editTableHandler: React.MouseEventHandler = + useCallback( + (e) => { + e.stopPropagation(); + if (readonly) { + return; + } - closeAllTablesInSidebar(); - setEditTableModeTable({ tableId: table.id }); - }, [table.id, setEditTableModeTable, closeAllTablesInSidebar, readonly]); + closeAllTablesInSidebar(); + setEditTableModeTable({ tableId: table.id }); + }, + [table.id, setEditTableModeTable, closeAllTablesInSidebar, readonly] + ); - const removeTableHandler = useCallback(() => { - removeTable(table.id); - }, [removeTable, table.id]); + const removeTableHandler: React.MouseEventHandler = + useCallback( + (e) => { + e.stopPropagation(); + removeTable(table.id); + }, + [removeTable, table.id] + ); - const addRelationshipHandler = useCallback(() => { - openCreateRelationshipDialog({ - sourceTableId: table.id, - }); - }, [openCreateRelationshipDialog, table.id]); + const addRelationshipHandler: React.MouseEventHandler = + useCallback( + (e) => { + e.stopPropagation(); + startFloatingEdgeCreation({ + sourceNodeId: table.id, + }); + }, + [startFloatingEdgeCreation, table.id] + ); if (!isDesktop || readonly) { return <>{children}; diff --git a/src/pages/editor-page/canvas/table-node/table-node.tsx b/src/pages/editor-page/canvas/table-node/table-node.tsx index dd6582125..831f4130d 100644 --- a/src/pages/editor-page/canvas/table-node/table-node.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node.tsx @@ -6,7 +6,13 @@ import React, { useEffect, } from 'react'; import type { NodeProps, Node } from '@xyflow/react'; -import { NodeResizer, useConnection, useStore } from '@xyflow/react'; +import { + NodeResizer, + useConnection, + useStore, + Handle, + Position, +} from '@xyflow/react'; import { Button } from '@/components/button/button'; import { ChevronsLeftRight, @@ -47,6 +53,9 @@ import { TableNodeStatus } from './table-node-status/table-node-status'; import { TableEditMode } from './table-edit-mode/table-edit-mode'; import { useCanvas } from '@/hooks/use-canvas'; +export const TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX = 'table_rel_source_'; +export const TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX = 'table_rel_target_'; + export type TableNodeType = Node< { table: DBTable; @@ -54,6 +63,7 @@ export type TableNodeType = Node< highlightOverlappingTables?: boolean; hasHighlightedCustomType?: boolean; highlightTable?: boolean; + isRelationshipCreatingTarget?: boolean; }, 'table' >; @@ -69,6 +79,7 @@ export const TableNode: React.FC> = React.memo( highlightOverlappingTables, hasHighlightedCustomType, highlightTable, + isRelationshipCreatingTarget, }, }) => { const { updateTable, relationships, readonly } = useChartDB(); @@ -81,7 +92,13 @@ export const TableNode: React.FC> = React.memo( const [expanded, setExpanded] = useState(table.expanded ?? false); const { t } = useTranslation(); const [isHovering, setIsHovering] = useState(false); - const { setEditTableModeTable, editTableModeTable } = useCanvas(); + const { + setEditTableModeTable, + editTableModeTable, + setHoveringTableId, + showCreateRelationshipNode, + tempFloatingEdge, + } = useCanvas(); // Get edit mode state directly from context const editTableMode = useMemo( @@ -314,11 +331,20 @@ export const TableNode: React.FC> = React.memo( editModeInitialFieldCount, ]); + const isPartOfCreatingRelationship = useMemo( + () => + tempFloatingEdge?.sourceNodeId === id || + (isRelationshipCreatingTarget && + tempFloatingEdge?.targetNodeId === id) || + isHovering, + [tempFloatingEdge, id, isRelationshipCreatingTarget, isHovering] + ); + const tableClassName = useMemo( () => cn( 'flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-sm transition-transform duration-300', - selected || isTarget + selected || isTarget || isPartOfCreatingRelationship ? 'border-pink-600' : 'border-slate-500 dark:border-slate-700', isOverlapping @@ -363,6 +389,7 @@ export const TableNode: React.FC> = React.memo( isDiffTableRemoved, isTarget, editTableMode, + isPartOfCreatingRelationship, ] ); @@ -400,11 +427,33 @@ export const TableNode: React.FC> = React.memo( className={tableClassName} onClick={(e) => { if (e.detail === 2 && !readonly) { + e.stopPropagation(); enterEditTableMode(); + } else if (e.detail === 1 && !readonly) { + // Handle single click + if ( + isRelationshipCreatingTarget && + tempFloatingEdge + ) { + e.stopPropagation(); + showCreateRelationshipNode({ + sourceTableId: + tempFloatingEdge.sourceNodeId, + targetTableId: table.id, + x: e.clientX, + y: e.clientY, + }); + } } }} - onMouseEnter={() => setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} + onMouseEnter={() => { + setIsHovering(true); + setHoveringTableId(table.id); + }} + onMouseLeave={() => { + setIsHovering(false); + setHoveringTableId(null); + }} > > = React.memo( shouldResize={(event) => event.dy === 0} handleClassName="!hidden" /> + {/* Center handle for floating edge creation */} + {!readonly ? ( + + ) : null} + {/* Target handle covering entire table for floating edge creation */} + {!readonly ? ( + + ) : null} ; + +export const TempCursorNode: React.FC> = + React.memo(() => { + // Invisible node that just serves as a connection point + return ( +
+ +
+ ); + }); + +TempCursorNode.displayName = 'TempCursorNode'; diff --git a/src/pages/editor-page/canvas/temp-floating-edge/temp-floating-edge.tsx b/src/pages/editor-page/canvas/temp-floating-edge/temp-floating-edge.tsx new file mode 100644 index 000000000..3b18cd56e --- /dev/null +++ b/src/pages/editor-page/canvas/temp-floating-edge/temp-floating-edge.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import type { Edge, EdgeProps } from '@xyflow/react'; +import { getSmoothStepPath, Position } from '@xyflow/react'; + +export const TEMP_FLOATING_EDGE_ID = '__temp_floating_edge__'; + +export type TempFloatingEdgeType = Edge< + { + // No relationship data - this is a temporary visual edge + }, + 'temp-floating-edge' +>; + +export const TempFloatingEdge: React.FC> = + React.memo( + ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition = Position.Right, + targetPosition = Position.Left, + }) => { + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 14, + }); + + return ( + + + + + ); + } + ); + +TempFloatingEdge.displayName = 'TempFloatingEdge'; From 7ad0e7712de975a23b2a337dc0a4a7fb4b122bd1 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Thu, 16 Oct 2025 17:37:20 +0300 Subject: [PATCH 031/156] fix: manipulate schema directly from the canvas (#947) --- .../canvas/canvas-context-menu.tsx | 91 ++++----- .../table-edit-mode/table-edit-mode.tsx | 185 ++++++++++++++++-- 2 files changed, 202 insertions(+), 74 deletions(-) diff --git a/src/pages/editor-page/canvas/canvas-context-menu.tsx b/src/pages/editor-page/canvas/canvas-context-menu.tsx index 3dd17548f..6fcb83481 100644 --- a/src/pages/editor-page/canvas/canvas-context-menu.tsx +++ b/src/pages/editor-page/canvas/canvas-context-menu.tsx @@ -14,14 +14,14 @@ import { Table, Workflow, Group, View } from 'lucide-react'; import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter'; import { useLocalConfig } from '@/hooks/use-local-config'; import { useCanvas } from '@/hooks/use-canvas'; -import type { DBTable } from '@/lib/domain'; +import { defaultSchemas } from '@/lib/data/default-schemas'; export const CanvasContextMenu: React.FC = ({ children, }) => { - const { createTable, readonly, createArea } = useChartDB(); + const { createTable, readonly, createArea, databaseType } = useChartDB(); const { schemasDisplayed } = useDiagramFilter(); - const { openCreateRelationshipDialog, openTableSchemaDialog } = useDialog(); + const { openCreateRelationshipDialog } = useDialog(); const { screenToFlowPosition } = useReactFlow(); const { t } = useTranslation(); const { showDBViews } = useLocalConfig(); @@ -36,31 +36,24 @@ export const CanvasContextMenu: React.FC = ({ y: event.clientY, }); - let newTable: DBTable | null = null; - - if (schemasDisplayed.length > 1) { - openTableSchemaDialog({ - onConfirm: async ({ schema }) => { - newTable = await createTable({ - x: position.x, - y: position.y, - schema: schema.name, - }); - }, - schemas: schemasDisplayed, - }); - } else { - const schema = - schemasDisplayed?.length === 1 - ? schemasDisplayed[0]?.name - : undefined; - newTable = await createTable({ - x: position.x, - y: position.y, - schema, - }); + // Auto-select schema with priority: default schema > first displayed schema > undefined + let schema: string | undefined = undefined; + if (schemasDisplayed.length > 0) { + const defaultSchemaName = defaultSchemas[databaseType]; + const defaultSchemaInList = schemasDisplayed.find( + (s) => s.name === defaultSchemaName + ); + schema = defaultSchemaInList + ? defaultSchemaInList.name + : schemasDisplayed[0]?.name; } + const newTable = await createTable({ + x: position.x, + y: position.y, + schema, + }); + if (newTable) { setEditTableModeTable({ tableId: newTable.id }); } @@ -68,9 +61,9 @@ export const CanvasContextMenu: React.FC = ({ [ createTable, screenToFlowPosition, - openTableSchemaDialog, schemasDisplayed, setEditTableModeTable, + databaseType, ] ); @@ -81,33 +74,25 @@ export const CanvasContextMenu: React.FC = ({ y: event.clientY, }); - let newView: DBTable | null = null; - - if (schemasDisplayed.length > 1) { - openTableSchemaDialog({ - onConfirm: async ({ schema }) => { - newView = await createTable({ - x: position.x, - y: position.y, - schema: schema.name, - isView: true, - }); - }, - schemas: schemasDisplayed, - }); - } else { - const schema = - schemasDisplayed?.length === 1 - ? schemasDisplayed[0]?.name - : undefined; - newView = await createTable({ - x: position.x, - y: position.y, - schema, - isView: true, - }); + // Auto-select schema with priority: default schema > first displayed schema > undefined + let schema: string | undefined = undefined; + if (schemasDisplayed.length > 0) { + const defaultSchemaName = defaultSchemas[databaseType]; + const defaultSchemaInList = schemasDisplayed.find( + (s) => s.name === defaultSchemaName + ); + schema = defaultSchemaInList + ? defaultSchemaInList.name + : schemasDisplayed[0]?.name; } + const newView = await createTable({ + x: position.x, + y: position.y, + schema, + isView: true, + }); + if (newView) { setEditTableModeTable({ tableId: newView.id }); } @@ -115,9 +100,9 @@ export const CanvasContextMenu: React.FC = ({ [ createTable, screenToFlowPosition, - openTableSchemaDialog, schemasDisplayed, setEditTableModeTable, + databaseType, ] ); diff --git a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx index 655b45905..c62f4ce31 100644 --- a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx +++ b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx @@ -1,7 +1,13 @@ import { Input } from '@/components/input/input'; import type { DBTable } from '@/lib/domain'; -import { FileType2, X } from 'lucide-react'; -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { FileType2, X, SquarePlus } from 'lucide-react'; +import React, { + useEffect, + useState, + useRef, + useCallback, + useMemo, +} from 'react'; import { TableEditModeField } from './table-edit-mode-field'; import { cn } from '@/lib/utils'; import { ScrollArea } from '@/components/scroll-area/scroll-area'; @@ -11,6 +17,14 @@ import { Separator } from '@/components/separator/separator'; import { useChartDB } from '@/hooks/use-chartdb'; import { useUpdateTable } from '@/hooks/use-update-table'; import { useTranslation } from 'react-i18next'; +import { SelectBox } from '@/components/select-box/select-box'; +import type { SelectBoxOption } from '@/components/select-box/select-box'; +import { + databasesWithSchemas, + schemaNameToSchemaId, +} from '@/lib/domain/db-schema'; +import type { DBSchema } from '@/lib/domain/db-schema'; +import { defaultSchemas } from '@/lib/data/default-schemas'; export interface TableEditModeProps { table: DBTable; @@ -25,7 +39,8 @@ export const TableEditMode: React.FC = React.memo( const scrollAreaRef = useRef(null); const fieldRefs = useRef>(new Map()); const [isVisible, setIsVisible] = useState(false); - const { createField, updateTable } = useChartDB(); + const { createField, updateTable, schemas, databaseType } = + useChartDB(); const { t } = useTranslation(); const { tableName, handleTableNameChange } = useUpdateTable(table); const [focusFieldId, setFocusFieldId] = useState( @@ -33,6 +48,39 @@ export const TableEditMode: React.FC = React.memo( ); const inputRef = useRef(null); + // Schema-related state + const [isCreatingNewSchema, setIsCreatingNewSchema] = useState(false); + const [newSchemaName, setNewSchemaName] = useState(''); + const [selectedSchemaId, setSelectedSchemaId] = useState(() => + table.schema ? schemaNameToSchemaId(table.schema) : '' + ); + + // Sync selectedSchemaId when table.schema changes + useEffect(() => { + setSelectedSchemaId( + table.schema ? schemaNameToSchemaId(table.schema) : '' + ); + }, [table.schema]); + + const supportsSchemas = useMemo( + () => databasesWithSchemas.includes(databaseType), + [databaseType] + ); + + const defaultSchemaName = useMemo( + () => defaultSchemas?.[databaseType], + [databaseType] + ); + + const schemaOptions: SelectBoxOption[] = useMemo( + () => + schemas.map((schema) => ({ + value: schema.id, + label: schema.name, + })), + [schemas] + ); + useEffect(() => { setFocusFieldId(focusFieldIdProp); if (!focusFieldIdProp) { @@ -115,6 +163,43 @@ export const TableEditMode: React.FC = React.memo( [updateTable, table.id] ); + const handleSchemaChange = useCallback( + (schemaId: string) => { + const schema = schemas.find((s) => s.id === schemaId); + if (schema) { + updateTable(table.id, { schema: schema.name }); + setSelectedSchemaId(schemaId); + } + }, + [schemas, updateTable, table.id] + ); + + const handleCreateNewSchema = useCallback(() => { + if (newSchemaName.trim()) { + const trimmedName = newSchemaName.trim(); + const newSchema: DBSchema = { + id: schemaNameToSchemaId(trimmedName), + name: trimmedName, + tableCount: 0, + }; + updateTable(table.id, { schema: newSchema.name }); + setSelectedSchemaId(newSchema.id); + setIsCreatingNewSchema(false); + setNewSchemaName(''); + } + }, [newSchemaName, updateTable, table.id]); + + const handleToggleSchemaMode = useCallback(() => { + if (isCreatingNewSchema && newSchemaName.trim()) { + // If we're leaving create mode with a value, create the schema + handleCreateNewSchema(); + } else { + // Otherwise just toggle modes + setIsCreatingNewSchema(!isCreatingNewSchema); + setNewSchemaName(''); + } + }, [isCreatingNewSchema, newSchemaName, handleCreateNewSchema]); + return (
= React.memo( onClick={(e) => e.stopPropagation()} >
-
+
- e.stopPropagation()} - popoverOnClick={(e) => e.stopPropagation()} - /> + {supportsSchemas && !isCreatingNewSchema && ( + + handleSchemaChange(value as string) + } + placeholder={ + defaultSchemaName || 'Select schema' + } + className="h-6 min-h-6 w-20 shrink-0 rounded-sm border-slate-600 bg-background py-0 pl-2 pr-0.5 text-sm" + popoverClassName="w-[200px]" + commandOnMouseDown={(e) => e.stopPropagation()} + commandOnClick={(e) => e.stopPropagation()} + footerButtons={ + + } + /> + )} + {supportsSchemas && isCreatingNewSchema && ( + + setNewSchemaName(e.target.value) + } + placeholder={`Enter schema name${defaultSchemaName ? ` (e.g. ${defaultSchemaName})` : ''}`} + className="h-6 w-28 shrink-0 rounded-sm border-slate-600 bg-background text-sm" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreateNewSchema(); + } else if (e.key === 'Escape') { + handleToggleSchemaMode(); + } + }} + onBlur={handleToggleSchemaMode} + autoFocus + /> + )} = React.memo( -
- +
+
+ {!table.isView ? ( + <> + + e.stopPropagation() + } + popoverOnClick={(e) => e.stopPropagation()} + /> + + ) : ( +
+ )} + +
{table.fields.length}{' '} {t('side_panel.tables_section.table.fields')} From 459698b5d0a1ff23a3719c2e55e4ab2e2384c4fe Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Thu, 16 Oct 2025 21:07:55 +0300 Subject: [PATCH 032/156] fix: add support for parsing default values in DBML (#948) --- src/lib/data/sql-export/export-sql-script.ts | 53 ++++++++++++++++++- .../__tests__/dbml-import-cases.test.ts | 47 ++++++++++++++++ src/lib/dbml/dbml-import/dbml-import.ts | 17 ++++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index fc4a043c1..d7b5e21db 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -13,6 +13,55 @@ import { exportSQLite } from './export-per-type/sqlite'; import { exportMySQL } from './export-per-type/mysql'; import { escapeSQLComment } from './export-per-type/common'; +// Function to format default values with proper quoting +const formatDefaultValue = (value: string): string => { + const trimmed = value.trim(); + + // SQL keywords and function-like keywords that don't need quotes + const keywords = [ + 'TRUE', + 'FALSE', + 'NULL', + 'CURRENT_TIMESTAMP', + 'CURRENT_DATE', + 'CURRENT_TIME', + 'NOW', + 'GETDATE', + 'NEWID', + 'UUID', + ]; + if (keywords.includes(trimmed.toUpperCase())) { + return trimmed; + } + + // Function calls (contain parentheses) don't need quotes + if (trimmed.includes('(') && trimmed.includes(')')) { + return trimmed; + } + + // Numbers don't need quotes + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + return trimmed; + } + + // Already quoted strings - keep as is + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed; + } + + // Check if it's a simple identifier (alphanumeric, no spaces) that might be a currency or enum + // These typically don't have spaces and are short (< 10 chars) + if (/^[A-Z][A-Z0-9_]*$/i.test(trimmed) && trimmed.length <= 10) { + return trimmed; // Treat as unquoted identifier (e.g., EUR, USD) + } + + // Everything else needs to be quoted and escaped + return `'${trimmed.replace(/'/g, "''")}'`; +}; + // Function to simplify verbose data type names const simplifyDataType = (typeName: string): string => { const typeMap: Record = {}; @@ -391,7 +440,9 @@ export const exportBaseSQL = ({ } } - sqlScript += ` DEFAULT ${fieldDefault}`; + // Format default value with proper quoting + const formattedDefault = formatDefaultValue(fieldDefault); + sqlScript += ` DEFAULT ${formattedDefault}`; } } diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts index ef8f5dec1..170fc02b4 100644 --- a/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts +++ b/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts @@ -295,4 +295,51 @@ describe('DBML Import cases', () => { it('should handle case 2 - tables with relationships', async () => { await testDBMLImportCase('2'); }); + + it('should handle table with default values', async () => { + const dbmlContent = `Table "public"."products" { + "id" bigint [pk, not null] + "name" varchar(255) [not null] + "price" decimal(10,2) [not null, default: 0] + "is_active" boolean [not null, default: true] + "status" varchar(50) [not null, default: "deprecated"] + "description" varchar(100) [default: \`complex "value" with quotes\`] + "created_at" timestamp [not null, default: "now()"] + + Indexes { + (name) [name: "idx_products_name"] + } +}`; + + const result = await importDBMLToDiagram(dbmlContent, { + databaseType: DatabaseType.POSTGRESQL, + }); + + expect(result.tables).toHaveLength(1); + const table = result.tables![0]; + expect(table.name).toBe('products'); + expect(table.fields).toHaveLength(7); + + // Check numeric default (0) + const priceField = table.fields.find((f) => f.name === 'price'); + expect(priceField?.default).toBe('0'); + + // Check boolean default (true) + const isActiveField = table.fields.find((f) => f.name === 'is_active'); + expect(isActiveField?.default).toBe('true'); + + // Check string default with all quotes removed + const statusField = table.fields.find((f) => f.name === 'status'); + expect(statusField?.default).toBe('deprecated'); + + // Check backtick string - all quotes removed + const descField = table.fields.find((f) => f.name === 'description'); + expect(descField?.default).toBe('complex value with quotes'); + + // Check function default with all quotes removed + const createdAtField = table.fields.find( + (f) => f.name === 'created_at' + ); + expect(createdAtField?.default).toBe('now()'); + }); }); diff --git a/src/lib/dbml/dbml-import/dbml-import.ts b/src/lib/dbml/dbml-import/dbml-import.ts index 9ac25d849..b9c7b33d0 100644 --- a/src/lib/dbml/dbml-import/dbml-import.ts +++ b/src/lib/dbml/dbml-import/dbml-import.ts @@ -89,6 +89,7 @@ interface DBMLField { precision?: number | null; scale?: number | null; note?: string | { value: string } | null; + default?: string | null; } interface DBMLIndexColumn { @@ -334,6 +335,20 @@ export const importDBMLToDiagram = async ( schema: schemaName, note: table.note, fields: table.fields.map((field): DBMLField => { + // Extract default value and remove all quotes + let defaultValue: string | undefined; + if ( + field.dbdefault !== undefined && + field.dbdefault !== null + ) { + const rawDefault = String( + field.dbdefault.value + ); + // Remove ALL quotes (single, double, backticks) to clean the value + // The SQL export layer will handle adding proper quotes when needed + defaultValue = rawDefault.replace(/['"`]/g, ''); + } + return { name: field.name, type: field.type, @@ -342,6 +357,7 @@ export const importDBMLToDiagram = async ( not_null: field.not_null, increment: field.increment, note: field.note, + default: defaultValue, ...getFieldExtraAttributes(field, allEnums), } satisfies DBMLField; }), @@ -488,6 +504,7 @@ export const importDBMLToDiagram = async ( precision: field.precision, scale: field.scale, ...(fieldComment ? { comments: fieldComment } : {}), + ...(field.default ? { default: field.default } : {}), }; }); From 49328d8fbd7786f6c0c04cd5605d43a24cbf10ea Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Sun, 19 Oct 2025 17:47:39 +0300 Subject: [PATCH 033/156] fix: add support for arrays (#949) * feat: add array field support with diff visualization * some refactor * fix * fix * fix --------- Co-authored-by: Guy Ben-Aharon --- src/context/diff-context/diff-context.tsx | 5 + src/context/diff-context/diff-provider.tsx | 25 ++ .../import-database/import-database.tsx | 2 +- src/hooks/use-update-table-field.ts | 73 +++- .../data/data-types/clickhouse-data-types.ts | 3 - src/lib/data/data-types/data-types.ts | 20 + .../data/data-types/postgres-data-types.ts | 1 - .../sql-export/__tests__/array-fields.test.ts | 356 ++++++++++++++++++ src/lib/data/sql-export/export-sql-script.ts | 12 +- .../__tests__/dbml-array-fields.test.ts | 317 ++++++++++++++++ .../dbml-import-fantasy-examples.test.ts | 57 ++- .../dbml-import/__tests__/dbml-import.test.ts | 21 +- .../__tests__/dbml-integration.test.ts | 20 +- src/lib/dbml/dbml-import/dbml-import-error.ts | 53 +++ src/lib/dbml/dbml-import/dbml-import.ts | 78 +++- src/lib/dbml/dbml-import/verify-dbml.ts | 19 +- src/lib/domain/database-capabilities.ts | 57 +++ src/lib/domain/database-type.ts | 6 - src/lib/domain/db-field.ts | 24 +- src/lib/domain/db-schema.ts | 15 +- src/lib/domain/diff/diff-check/diff-check.ts | 30 ++ src/lib/domain/diff/field-diff.ts | 4 +- .../table-edit-mode/table-edit-mode-field.tsx | 15 +- .../table-edit-mode/table-edit-mode.tsx | 1 + .../canvas/table-node/table-node-field.tsx | 214 +++++++---- .../table-field-modal/table-field-modal.tsx | 27 ++ .../table-field/table-field.tsx | 12 +- .../table-list-item-content.tsx | 3 + .../examples-data/examples-data.ts | 5 +- 29 files changed, 1314 insertions(+), 161 deletions(-) create mode 100644 src/lib/data/sql-export/__tests__/array-fields.test.ts create mode 100644 src/lib/dbml/dbml-import/__tests__/dbml-array-fields.test.ts create mode 100644 src/lib/domain/database-capabilities.ts diff --git a/src/context/diff-context/diff-context.tsx b/src/context/diff-context/diff-context.tsx index bb1532433..191b72da2 100644 --- a/src/context/diff-context/diff-context.tsx +++ b/src/context/diff-context/diff-context.tsx @@ -105,6 +105,11 @@ export interface DiffContext { }: { fieldId: string; }) => { old: number; new: number } | null; + getFieldNewIsArray: ({ + fieldId, + }: { + fieldId: string; + }) => { old: boolean; new: boolean } | null; // relationship diff checkIfNewRelationship: ({ diff --git a/src/context/diff-context/diff-provider.tsx b/src/context/diff-context/diff-provider.tsx index 5181234b1..59daad007 100644 --- a/src/context/diff-context/diff-provider.tsx +++ b/src/context/diff-context/diff-provider.tsx @@ -447,6 +447,30 @@ export const DiffProvider: React.FC = ({ [diffMap] ); + const getFieldNewIsArray = useCallback( + ({ fieldId }) => { + const fieldKey = getDiffMapKey({ + diffObject: 'field', + objectId: fieldId, + attribute: 'isArray', + }); + + if (diffMap.has(fieldKey)) { + const diff = diffMap.get(fieldKey); + + if (diff?.type === 'changed') { + return { + old: diff.oldValue as boolean, + new: diff.newValue as boolean, + }; + } + } + + return null; + }, + [diffMap] + ); + const checkIfNewRelationship = useCallback< DiffContext['checkIfNewRelationship'] >( @@ -520,6 +544,7 @@ export const DiffProvider: React.FC = ({ getFieldNewCharacterMaximumLength, getFieldNewScale, getFieldNewPrecision, + getFieldNewIsArray, // relationship diff checkIfNewRelationship, diff --git a/src/dialogs/common/import-database/import-database.tsx b/src/dialogs/common/import-database/import-database.tsx index e69a81620..07187a889 100644 --- a/src/dialogs/common/import-database/import-database.tsx +++ b/src/dialogs/common/import-database/import-database.tsx @@ -140,7 +140,7 @@ export const ImportDatabase: React.FC = ({ if (importMethod === 'dbml') { // Validate DBML by parsing it - const validateResponse = verifyDBML(scriptResult); + const validateResponse = verifyDBML(scriptResult, { databaseType }); if (!validateResponse.hasError) { setErrorMessage(''); setSqlValidation({ diff --git a/src/hooks/use-update-table-field.ts b/src/hooks/use-update-table-field.ts index 001844c62..72225c080 100644 --- a/src/hooks/use-update-table-field.ts +++ b/src/hooks/use-update-table-field.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; import { useChartDB } from './use-chartdb'; import { useDebounce } from './use-debounce-v2'; -import type { DBField, DBTable } from '@/lib/domain'; +import type { DatabaseType, DBField, DBTable } from '@/lib/domain'; import type { SelectBoxOption, SelectBoxProps, @@ -9,49 +9,60 @@ import type { import { dataTypeDataToDataType, sortedDataTypeMap, + supportsArrayDataType, } from '@/lib/data/data-types/data-types'; import { generateDBFieldSuffix } from '@/lib/domain/db-field'; import type { DataTypeData } from '@/lib/data/data-types/data-types'; const generateFieldRegexPatterns = ( - dataType: DataTypeData + dataType: DataTypeData, + databaseType: DatabaseType ): { regex?: string; extractRegex?: RegExp; } => { + const typeName = dataType.name; + const supportsArrays = supportsArrayDataType(dataType.id, databaseType); + const arrayPattern = supportsArrays ? '(\\[\\])?' : ''; + if (!dataType.fieldAttributes) { - return { regex: undefined, extractRegex: undefined }; + // For types without field attributes, support plain type + optional array notation + return { + regex: `^${typeName}${arrayPattern}$`, + extractRegex: new RegExp(`^${typeName}${arrayPattern}$`), + }; } - const typeName = dataType.name; const fieldAttributes = dataType.fieldAttributes; if (fieldAttributes.hasCharMaxLength) { if (fieldAttributes.hasCharMaxLengthOption) { return { - regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)$`, - extractRegex: /\((\d+|max)\)/i, + regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)${arrayPattern}$`, + extractRegex: supportsArrays + ? /\((\d+|max)\)(\[\])?/i + : /\((\d+|max)\)/i, }; } return { - regex: `^${typeName}\\(\\d+\\)$`, - extractRegex: /\((\d+)\)/, + regex: `^${typeName}\\(\\d+\\)${arrayPattern}$`, + extractRegex: supportsArrays ? /\((\d+)\)(\[\])?/ : /\((\d+)\)/, }; } if (fieldAttributes.precision && fieldAttributes.scale) { return { - regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`, + regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)${arrayPattern}$`, extractRegex: new RegExp( - `${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)` + `${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)${arrayPattern}` ), }; } if (fieldAttributes.precision) { return { - regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`, - extractRegex: /\((\d+)\)/, + regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)${arrayPattern}$`, + extractRegex: supportsArrays ? /\((\d+)\)(\[\])?/ : /\((\d+)\)/, }; } @@ -118,7 +129,10 @@ export const useUpdateTableField = ( const standardTypes: SelectBoxOption[] = sortedDataTypeMap[ databaseType ].map((type) => { - const regexPatterns = generateFieldRegexPatterns(type); + const regexPatterns = generateFieldRegexPatterns( + type, + databaseType + ); return { label: type.name, @@ -162,8 +176,13 @@ export const useUpdateTableField = ( let characterMaximumLength: string | undefined = undefined; let precision: number | undefined = undefined; let scale: number | undefined = undefined; + let isArray: boolean | undefined = undefined; if (regexMatches?.length) { + // Check if the last captured group is the array indicator [] + const lastMatch = regexMatches[regexMatches.length - 1]; + const hasArrayIndicator = lastMatch === '[]'; + if (dataType?.fieldAttributes?.hasCharMaxLength) { characterMaximumLength = regexMatches[1]?.toLowerCase(); } else if ( @@ -177,6 +196,17 @@ export const useUpdateTableField = ( } else if (dataType?.fieldAttributes?.precision) { precision = parseInt(regexMatches[1]); } + + // Set isArray if the array indicator was found and the type supports arrays + if (hasArrayIndicator) { + const typeId = value as string; + if (supportsArrayDataType(typeId, databaseType)) { + isArray = true; + } + } else { + // Explicitly set to false/undefined if no array indicator + isArray = undefined; + } } else { if ( dataType?.fieldAttributes?.hasCharMaxLength && @@ -198,6 +228,7 @@ export const useUpdateTableField = ( characterMaximumLength, precision, scale, + isArray, increment: undefined, default: undefined, type: dataTypeDataToDataType( @@ -299,11 +330,17 @@ export const useUpdateTableField = ( // Utility function to generate field suffix for display const generateFieldSuffix = useCallback( (typeId?: string) => { - return generateDBFieldSuffix(field, { - databaseType, - forceExtended: true, - typeId, - }); + return generateDBFieldSuffix( + { + ...field, + isArray: field.isArray && typeId === field.type.id, + }, + { + databaseType, + forceExtended: true, + typeId, + } + ); }, [field, databaseType] ); diff --git a/src/lib/data/data-types/clickhouse-data-types.ts b/src/lib/data/data-types/clickhouse-data-types.ts index 6f4c2d68e..0199ac4f7 100644 --- a/src/lib/data/data-types/clickhouse-data-types.ts +++ b/src/lib/data/data-types/clickhouse-data-types.ts @@ -129,9 +129,6 @@ export const clickhouseDataTypes: readonly DataTypeData[] = [ { name: 'enum', id: 'enum' }, { name: 'lowcardinality', id: 'lowcardinality' }, - // Array Type - { name: 'array', id: 'array' }, - // Tuple Type { name: 'tuple', id: 'tuple' }, { name: 'map', id: 'map' }, diff --git a/src/lib/data/data-types/data-types.ts b/src/lib/data/data-types/data-types.ts index e8cd17fae..f44b1910a 100644 --- a/src/lib/data/data-types/data-types.ts +++ b/src/lib/data/data-types/data-types.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { DatabaseType } from '../../domain/database-type'; +import { databaseSupportsArrays } from '../../domain/database-capabilities'; import { clickhouseDataTypes } from './clickhouse-data-types'; import { genericDataTypes } from './generic-data-types'; import { mariadbDataTypes } from './mariadb-data-types'; @@ -165,3 +166,22 @@ export const supportsAutoIncrementDataType = ( 'decimal', ].includes(dataTypeName.toLocaleLowerCase()); }; + +const ARRAY_INCOMPATIBLE_TYPES = [ + 'serial', + 'bigserial', + 'smallserial', +] as const; + +export const supportsArrayDataType = ( + dataTypeName: string, + databaseType: DatabaseType +): boolean => { + if (!databaseSupportsArrays(databaseType)) { + return false; + } + + return !ARRAY_INCOMPATIBLE_TYPES.includes( + dataTypeName.toLowerCase() as (typeof ARRAY_INCOMPATIBLE_TYPES)[number] + ); +}; diff --git a/src/lib/data/data-types/postgres-data-types.ts b/src/lib/data/data-types/postgres-data-types.ts index 81a680619..65a8c1562 100644 --- a/src/lib/data/data-types/postgres-data-types.ts +++ b/src/lib/data/data-types/postgres-data-types.ts @@ -97,7 +97,6 @@ export const postgresDataTypes: readonly DataTypeData[] = [ { name: 'tsvector', id: 'tsvector' }, { name: 'tsquery', id: 'tsquery' }, { name: 'xml', id: 'xml' }, - { name: 'array', id: 'array' }, { name: 'int4range', id: 'int4range' }, { name: 'int8range', id: 'int8range' }, { name: 'numrange', id: 'numrange' }, diff --git a/src/lib/data/sql-export/__tests__/array-fields.test.ts b/src/lib/data/sql-export/__tests__/array-fields.test.ts new file mode 100644 index 000000000..c5d849a22 --- /dev/null +++ b/src/lib/data/sql-export/__tests__/array-fields.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect } from 'vitest'; +import { generateId } from '@/lib/utils'; +import { exportBaseSQL } from '../export-sql-script'; +import { DatabaseType } from '@/lib/domain/database-type'; +import type { Diagram } from '@/lib/domain/diagram'; + +describe('SQL Export - Array Fields (Fantasy RPG Theme)', () => { + it('should export array fields for magical spell components', () => { + const diagram: Diagram = { + id: 'test-diagram', + name: 'Magical Spell System', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'spells', + schema: '', + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'uuid', name: 'uuid' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'name', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: false, + createdAt: Date.now(), + characterMaximumLength: '200', + }, + { + id: generateId(), + name: 'components', + type: { id: 'text', name: 'text' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + isArray: true, + comments: 'Magical components needed for the spell', + }, + { + id: generateId(), + name: 'elemental_types', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + characterMaximumLength: '50', + isArray: true, + comments: + 'Elements involved: fire, water, earth, air', + }, + ], + indexes: [], + x: 0, + y: 0, + color: '#3b82f6', + isView: false, + createdAt: Date.now(), + order: 0, + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const sql = exportBaseSQL({ + diagram, + targetDatabaseType: DatabaseType.POSTGRESQL, + isDBMLFlow: true, + }); + + expect(sql).toContain('CREATE TABLE "spells"'); + expect(sql).toContain('"components" text[]'); + expect(sql).toContain('"elemental_types" varchar(50)[]'); + }); + + it('should export array fields for hero inventory system', () => { + const diagram: Diagram = { + id: 'test-diagram', + name: 'RPG Inventory System', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'heroes', + schema: 'game', + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'bigint', name: 'bigint' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'name', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: false, + createdAt: Date.now(), + characterMaximumLength: '100', + }, + { + id: generateId(), + name: 'abilities', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + characterMaximumLength: '100', + isArray: true, + comments: + 'Special abilities like Stealth, Fireball, etc', + }, + { + id: generateId(), + name: 'inventory_slots', + type: { id: 'integer', name: 'integer' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + isArray: true, + comments: 'Item IDs in inventory', + }, + { + id: generateId(), + name: 'skill_levels', + type: { id: 'decimal', name: 'decimal' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + precision: 5, + scale: 2, + isArray: true, + comments: 'Skill proficiency levels', + }, + ], + indexes: [], + x: 0, + y: 0, + color: '#ef4444', + isView: false, + createdAt: Date.now(), + order: 0, + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const sql = exportBaseSQL({ + diagram, + targetDatabaseType: DatabaseType.POSTGRESQL, + isDBMLFlow: true, + }); + + expect(sql).toContain('CREATE TABLE "game"."heroes"'); + expect(sql).toContain('"abilities" varchar(100)[]'); + expect(sql).toContain('"inventory_slots" integer[]'); + expect(sql).toContain('"skill_levels" decimal(5, 2)[]'); + }); + + it('should export non-array fields normally when isArray is false or undefined', () => { + const diagram: Diagram = { + id: 'test-diagram', + name: 'Quest System', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'quests', + schema: '', + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'uuid', name: 'uuid' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'title', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: false, + createdAt: Date.now(), + characterMaximumLength: '200', + isArray: false, + }, + { + id: generateId(), + name: 'description', + type: { id: 'text', name: 'text' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + // isArray is undefined - should not be treated as array + }, + ], + indexes: [], + x: 0, + y: 0, + color: '#8b5cf6', + isView: false, + createdAt: Date.now(), + order: 0, + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const sql = exportBaseSQL({ + diagram, + targetDatabaseType: DatabaseType.POSTGRESQL, + isDBMLFlow: true, + }); + + expect(sql).toContain('"title" varchar(200)'); + expect(sql).not.toContain('"title" varchar(200)[]'); + expect(sql).toContain('"description" text'); + expect(sql).not.toContain('"description" text[]'); + }); + + it('should handle mixed array and non-array fields in magical creatures table', () => { + const diagram: Diagram = { + id: 'test-diagram', + name: 'Bestiary System', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'magical_creatures', + schema: 'bestiary', + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'bigint', name: 'bigint' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'species_name', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: false, + createdAt: Date.now(), + characterMaximumLength: '100', + }, + { + id: generateId(), + name: 'habitats', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + characterMaximumLength: '80', + isArray: true, + comments: + 'Preferred habitats: forest, mountain, swamp', + }, + { + id: generateId(), + name: 'danger_level', + type: { id: 'integer', name: 'integer' }, + primaryKey: false, + unique: false, + nullable: false, + createdAt: Date.now(), + default: '1', + }, + { + id: generateId(), + name: 'resistances', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + characterMaximumLength: '50', + isArray: true, + comments: 'Damage resistances', + }, + { + id: generateId(), + name: 'is_tameable', + type: { id: 'boolean', name: 'boolean' }, + primaryKey: false, + unique: false, + nullable: false, + createdAt: Date.now(), + default: 'false', + }, + ], + indexes: [], + x: 0, + y: 0, + color: '#10b981', + isView: false, + createdAt: Date.now(), + order: 0, + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const sql = exportBaseSQL({ + diagram, + targetDatabaseType: DatabaseType.POSTGRESQL, + isDBMLFlow: true, + }); + + expect(sql).toContain('CREATE TABLE "bestiary"."magical_creatures"'); + expect(sql).toContain('"species_name" varchar(100)'); + expect(sql).not.toContain('"species_name" varchar(100)[]'); + expect(sql).toContain('"habitats" varchar(80)[]'); + expect(sql).toContain('"danger_level" integer'); + expect(sql).not.toContain('"danger_level" integer[]'); + expect(sql).toContain('"resistances" varchar(50)[]'); + expect(sql).toContain('"is_tameable" boolean'); + expect(sql).not.toContain('"is_tameable" boolean[]'); + }); +}); diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index d7b5e21db..345041a5f 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -1,9 +1,6 @@ import type { Diagram } from '../../domain/diagram'; import { OPENAI_API_KEY, OPENAI_API_ENDPOINT, LLM_MODEL_NAME } from '@/lib/env'; -import { - DatabaseType, - databaseTypesWithCommentSupport, -} from '@/lib/domain/database-type'; +import { DatabaseType } from '@/lib/domain/database-type'; import type { DBTable } from '@/lib/domain/db-table'; import { dataTypeMap, type DataType } from '../data-types/data-types'; import { generateCacheKey, getFromCache, setInCache } from './export-sql-cache'; @@ -12,6 +9,7 @@ import { exportPostgreSQL } from './export-per-type/postgresql'; import { exportSQLite } from './export-per-type/sqlite'; import { exportMySQL } from './export-per-type/mysql'; import { escapeSQLComment } from './export-per-type/common'; +import { databaseTypesWithCommentSupport } from '@/lib/domain/database-capabilities'; // Function to format default values with proper quoting const formatDefaultValue = (value: string): string => { @@ -343,6 +341,7 @@ export const exportBaseSQL = ({ } const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow); + sqlScript += ` ${quotedFieldName} ${typeName}`; // Add size for character types @@ -385,6 +384,11 @@ export const exportBaseSQL = ({ } } + // Add array suffix if field is an array (after type size and precision) + if (field.isArray) { + sqlScript += '[]'; + } + // Handle NOT NULL constraint if (!field.nullable) { sqlScript += ' NOT NULL'; diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-array-fields.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-array-fields.test.ts new file mode 100644 index 000000000..943858b95 --- /dev/null +++ b/src/lib/dbml/dbml-import/__tests__/dbml-array-fields.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect } from 'vitest'; +import { importDBMLToDiagram } from '../dbml-import'; +import { generateDBMLFromDiagram } from '../../dbml-export/dbml-export'; +import { DatabaseType } from '@/lib/domain/database-type'; + +describe('DBML Array Fields - Fantasy RPG Theme', () => { + describe('Import - Spell and Magic Arrays', () => { + it('should import spell components as array fields', async () => { + const dbml = ` +Table "magic"."spells" { + "id" uuid [pk, not null] + "name" varchar(200) [not null] + "level" integer [not null] + "components" text[] [note: 'Magical components: bat wing, dragon scale, phoenix feather'] + "elemental_types" varchar(50)[] [note: 'Elements: fire, water, earth, air'] + "mana_cost" integer [not null] + "created_at" timestamp [not null] + + Indexes { + (name, level) [unique, name: "unique_spell"] + } +} +`; + + const result = await importDBMLToDiagram(dbml, { + databaseType: DatabaseType.POSTGRESQL, + }); + + expect(result.tables).toHaveLength(1); + + const table = result.tables![0]; + expect(table.name).toBe('spells'); + expect(table.schema).toBe('magic'); + + // Find the array fields + const components = table.fields.find( + (f) => f.name === 'components' + ); + const elementalTypes = table.fields.find( + (f) => f.name === 'elemental_types' + ); + + // Verify they are marked as arrays + expect(components).toBeDefined(); + expect(components?.isArray).toBe(true); + expect(components?.type.name).toBe('text'); + + expect(elementalTypes).toBeDefined(); + expect(elementalTypes?.isArray).toBe(true); + expect(elementalTypes?.type.name).toBe('varchar'); + expect(elementalTypes?.characterMaximumLength).toBe('50'); + + // Verify non-array fields don't have isArray set + const idField = table.fields.find((f) => f.name === 'id'); + expect(idField?.isArray).toBeUndefined(); + }); + + it('should import hero inventory with various array types', async () => { + const dbml = ` +Table "heroes" { + "id" bigint [pk] + "name" varchar(100) [not null] + "abilities" varchar(100)[] + "inventory_slots" integer[] + "skill_levels" decimal(5, 2)[] + "quest_log" text[] +} +`; + + const result = await importDBMLToDiagram(dbml, { + databaseType: DatabaseType.POSTGRESQL, + }); + + const table = result.tables![0]; + + const abilities = table.fields.find((f) => f.name === 'abilities'); + expect(abilities?.isArray).toBe(true); + expect(abilities?.type.name).toBe('varchar'); + expect(abilities?.characterMaximumLength).toBe('100'); + + const inventorySlots = table.fields.find( + (f) => f.name === 'inventory_slots' + ); + expect(inventorySlots?.isArray).toBe(true); + expect(inventorySlots?.type.name).toBe('integer'); + + const skillLevels = table.fields.find( + (f) => f.name === 'skill_levels' + ); + expect(skillLevels?.isArray).toBe(true); + expect(skillLevels?.type.name).toBe('decimal'); + expect(skillLevels?.precision).toBe(5); + expect(skillLevels?.scale).toBe(2); + + const questLog = table.fields.find((f) => f.name === 'quest_log'); + expect(questLog?.isArray).toBe(true); + expect(questLog?.type.name).toBe('text'); + }); + + it('should handle mixed array and non-array fields in creature table', async () => { + const dbml = ` +Table "bestiary"."creatures" { + "id" uuid [pk] + "species_name" varchar(100) [not null] + "habitats" varchar(50)[] + "danger_level" integer [not null] + "resistances" varchar(50)[] + "is_tameable" boolean [not null] +} +`; + + const result = await importDBMLToDiagram(dbml, { + databaseType: DatabaseType.POSTGRESQL, + }); + + const table = result.tables![0]; + + // Non-array fields + const id = table.fields.find((f) => f.name === 'id'); + expect(id?.isArray).toBeUndefined(); + + const speciesName = table.fields.find( + (f) => f.name === 'species_name' + ); + expect(speciesName?.isArray).toBeUndefined(); + + const dangerLevel = table.fields.find( + (f) => f.name === 'danger_level' + ); + expect(dangerLevel?.isArray).toBeUndefined(); + + // Array fields + const habitats = table.fields.find((f) => f.name === 'habitats'); + expect(habitats?.isArray).toBe(true); + + const resistances = table.fields.find( + (f) => f.name === 'resistances' + ); + expect(resistances?.isArray).toBe(true); + }); + }); + + describe('Round-trip - Quest and Adventure Arrays', () => { + it('should preserve quest rewards array through export and re-import', async () => { + const originalDbml = ` +Table "adventures"."quests" { + "id" uuid [pk, not null] + "title" varchar(200) [not null] + "difficulty" varchar(20) [not null] + "reward_items" text[] [note: 'Legendary sword, enchanted armor, healing potion'] + "required_skills" varchar(100)[] + "experience_points" integer [not null] + "gold_reward" decimal(10, 2) [not null] + "created_at" timestamp [not null] + + Indexes { + (title, difficulty) [unique, name: "unique_quest"] + } +} +`; + + // Import the DBML + const diagram = await importDBMLToDiagram(originalDbml, { + databaseType: DatabaseType.POSTGRESQL, + }); + + // Verify array fields were imported correctly + const table = diagram.tables![0]; + const rewardItems = table.fields.find( + (f) => f.name === 'reward_items' + ); + const requiredSkills = table.fields.find( + (f) => f.name === 'required_skills' + ); + + expect(rewardItems?.isArray).toBe(true); + expect(requiredSkills?.isArray).toBe(true); + + // Export back to DBML + const { standardDbml: exportedDbml } = + generateDBMLFromDiagram(diagram); + + // Verify the exported DBML contains array syntax + expect(exportedDbml).toContain('text[]'); + expect(exportedDbml).toContain('"reward_items" text[]'); + expect(exportedDbml).toContain('"required_skills" varchar(100)[]'); + + // Re-import the exported DBML + const reimportedDiagram = await importDBMLToDiagram(exportedDbml, { + databaseType: DatabaseType.POSTGRESQL, + }); + + // Verify array fields are still marked as arrays + const reimportedTable = reimportedDiagram.tables![0]; + const reimportedRewards = reimportedTable.fields.find( + (f) => f.name === 'reward_items' + ); + const reimportedSkills = reimportedTable.fields.find( + (f) => f.name === 'required_skills' + ); + + expect(reimportedRewards?.isArray).toBe(true); + expect(reimportedSkills?.isArray).toBe(true); + }); + + it('should handle guild members with different array types in round-trip', async () => { + const originalDbml = ` +Table "guilds"."members" { + "id" uuid [pk] + "name" varchar(100) [not null] + "class_specializations" varchar(50)[] + "completed_quest_ids" integer[] + "skill_ratings" decimal(3, 1)[] + "titles_earned" text[] +} +`; + + // Import + const diagram = await importDBMLToDiagram(originalDbml, { + databaseType: DatabaseType.POSTGRESQL, + }); + + // Export + const { standardDbml: exportedDbml } = + generateDBMLFromDiagram(diagram); + + // Verify exported DBML has correct array syntax with types + expect(exportedDbml).toContain('varchar(50)[]'); + expect(exportedDbml).toContain('integer[]'); + expect(exportedDbml).toContain('decimal(3,1)[]'); + expect(exportedDbml).toContain('text[]'); + + // Re-import + const reimportedDiagram = await importDBMLToDiagram(exportedDbml, { + databaseType: DatabaseType.POSTGRESQL, + }); + + const table = reimportedDiagram.tables![0]; + + const classSpecs = table.fields.find( + (f) => f.name === 'class_specializations' + ); + expect(classSpecs?.isArray).toBe(true); + expect(classSpecs?.characterMaximumLength).toBe('50'); + + const questIds = table.fields.find( + (f) => f.name === 'completed_quest_ids' + ); + expect(questIds?.isArray).toBe(true); + + const skillRatings = table.fields.find( + (f) => f.name === 'skill_ratings' + ); + expect(skillRatings?.isArray).toBe(true); + expect(skillRatings?.precision).toBe(3); + expect(skillRatings?.scale).toBe(1); + + const titles = table.fields.find((f) => f.name === 'titles_earned'); + expect(titles?.isArray).toBe(true); + }); + + it('should preserve dungeon loot tables with mixed array and non-array fields', async () => { + const originalDbml = ` +Table "dungeons"."loot_tables" { + "id" bigint [pk] + "dungeon_name" varchar(150) [not null] + "boss_name" varchar(100) + "common_drops" text[] + "rare_drops" text[] + "legendary_drops" text[] + "gold_range_min" integer [not null] + "gold_range_max" integer [not null] + "drop_rates" decimal(5, 2)[] +} +`; + + // Import, export, and re-import + const diagram = await importDBMLToDiagram(originalDbml, { + databaseType: DatabaseType.POSTGRESQL, + }); + + const { standardDbml: exportedDbml } = + generateDBMLFromDiagram(diagram); + + const reimportedDiagram = await importDBMLToDiagram(exportedDbml, { + databaseType: DatabaseType.POSTGRESQL, + }); + + const table = reimportedDiagram.tables![0]; + + // Verify non-array fields + expect( + table.fields.find((f) => f.name === 'id')?.isArray + ).toBeUndefined(); + expect( + table.fields.find((f) => f.name === 'dungeon_name')?.isArray + ).toBeUndefined(); + expect( + table.fields.find((f) => f.name === 'gold_range_min')?.isArray + ).toBeUndefined(); + + // Verify array fields + expect( + table.fields.find((f) => f.name === 'common_drops')?.isArray + ).toBe(true); + expect( + table.fields.find((f) => f.name === 'rare_drops')?.isArray + ).toBe(true); + expect( + table.fields.find((f) => f.name === 'legendary_drops')?.isArray + ).toBe(true); + expect( + table.fields.find((f) => f.name === 'drop_rates')?.isArray + ).toBe(true); + }); + }); +}); diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-import-fantasy-examples.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-import-fantasy-examples.test.ts index c81be0a18..1393db708 100644 --- a/src/lib/dbml/dbml-import/__tests__/dbml-import-fantasy-examples.test.ts +++ b/src/lib/dbml/dbml-import/__tests__/dbml-import-fantasy-examples.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { importDBMLToDiagram } from '../dbml-import'; import { DBCustomTypeKind } from '@/lib/domain/db-custom-type'; +import { DatabaseType } from '@/lib/domain/database-type'; describe('DBML Import - Fantasy Examples', () => { describe('Magical Academy System', () => { @@ -149,7 +150,9 @@ Table ranks { max_spell_level integer [not null] }`; - const diagram = await importDBMLToDiagram(magicalAcademyDBML); + const diagram = await importDBMLToDiagram(magicalAcademyDBML, { + databaseType: DatabaseType.POSTGRESQL, + }); // Verify tables expect(diagram.tables).toHaveLength(8); @@ -366,7 +369,9 @@ Note marketplace_note { 'This marketplace handles both standard purchases and barter trades' }`; - const diagram = await importDBMLToDiagram(marketplaceDBML); + const diagram = await importDBMLToDiagram(marketplaceDBML, { + databaseType: DatabaseType.POSTGRESQL, + }); // Verify tables expect(diagram.tables).toHaveLength(7); @@ -567,7 +572,9 @@ Note quest_system_note { 'Quest difficulty and status use enums that will be converted to varchar' }`; - const diagram = await importDBMLToDiagram(questSystemDBML); + const diagram = await importDBMLToDiagram(questSystemDBML, { + databaseType: DatabaseType.POSTGRESQL, + }); // Verify tables expect(diagram.tables).toHaveLength(7); @@ -657,7 +664,9 @@ Table projects { priority enum // inline enum without values - will be converted to varchar }`; - const diagram = await importDBMLToDiagram(dbmlWithEnums); + const diagram = await importDBMLToDiagram(dbmlWithEnums, { + databaseType: DatabaseType.POSTGRESQL, + }); // Verify customTypes are created for enums expect(diagram.customTypes).toBeDefined(); @@ -744,7 +753,9 @@ Table orders { status order_status [not null] }`; - const diagram = await importDBMLToDiagram(dbmlWithEnumNotes); + const diagram = await importDBMLToDiagram(dbmlWithEnumNotes, { + databaseType: DatabaseType.POSTGRESQL, + }); // Verify enum is created expect(diagram.customTypes).toHaveLength(1); @@ -788,7 +799,9 @@ Table admin.users { status admin.status }`; - const diagram = await importDBMLToDiagram(dbmlWithSameEnumNames); + const diagram = await importDBMLToDiagram(dbmlWithSameEnumNames, { + databaseType: DatabaseType.POSTGRESQL, + }); // Verify both enums are created expect(diagram.customTypes).toHaveLength(2); @@ -891,7 +904,9 @@ Note dragon_note { 'Dragons are very protective of their hoards!' }`; - const diagram = await importDBMLToDiagram(edgeCaseDBML); + const diagram = await importDBMLToDiagram(edgeCaseDBML, { + databaseType: DatabaseType.POSTGRESQL, + }); // Verify preprocessing worked expect(diagram.tables).toHaveLength(2); @@ -956,7 +971,9 @@ Note dragon_note { it('should handle empty DBML gracefully', async () => { const emptyDBML = ''; - const diagram = await importDBMLToDiagram(emptyDBML); + const diagram = await importDBMLToDiagram(emptyDBML, { + databaseType: DatabaseType.POSTGRESQL, + }); expect(diagram.tables).toHaveLength(0); expect(diagram.relationships).toHaveLength(0); @@ -969,7 +986,9 @@ Note dragon_note { /* Multi-line comment */ `; - const diagram = await importDBMLToDiagram(commentOnlyDBML); + const diagram = await importDBMLToDiagram(commentOnlyDBML, { + databaseType: DatabaseType.POSTGRESQL, + }); expect(diagram.tables).toHaveLength(0); expect(diagram.relationships).toHaveLength(0); @@ -980,7 +999,9 @@ Note dragon_note { Table empty_table { id int }`; - const diagram = await importDBMLToDiagram(minimalDBML); + const diagram = await importDBMLToDiagram(minimalDBML, { + databaseType: DatabaseType.POSTGRESQL, + }); expect(diagram.tables).toHaveLength(1); expect(diagram.tables?.[0]?.fields).toHaveLength(1); @@ -996,7 +1017,9 @@ Table "aa"."users" { Table "bb"."users" { id integer [primary key] }`; - const diagram = await importDBMLToDiagram(dbml); + const diagram = await importDBMLToDiagram(dbml, { + databaseType: DatabaseType.POSTGRESQL, + }); expect(diagram.tables).toHaveLength(2); @@ -1071,7 +1094,9 @@ Table "public_3"."comments" { id [unique, name: "public_3_index_1"] } }`; - const diagram = await importDBMLToDiagram(dbml); + const diagram = await importDBMLToDiagram(dbml, { + databaseType: DatabaseType.POSTGRESQL, + }); // Verify tables expect(diagram.tables).toHaveLength(3); @@ -1256,7 +1281,9 @@ Table products { Note: 'This table stores product information' }`; - const diagram = await importDBMLToDiagram(dbmlWithTableNote); + const diagram = await importDBMLToDiagram(dbmlWithTableNote, { + databaseType: DatabaseType.POSTGRESQL, + }); expect(diagram.tables).toHaveLength(1); const productsTable = diagram.tables?.[0]; @@ -1273,7 +1300,9 @@ Table orders { total numeric(10,2) [note: 'Order total including tax'] }`; - const diagram = await importDBMLToDiagram(dbmlWithFieldNote); + const diagram = await importDBMLToDiagram(dbmlWithFieldNote, { + databaseType: DatabaseType.POSTGRESQL, + }); expect(diagram.tables).toHaveLength(1); const ordersTable = diagram.tables?.[0]; diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-import.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-import.test.ts index 694853b86..850d6f776 100644 --- a/src/lib/dbml/dbml-import/__tests__/dbml-import.test.ts +++ b/src/lib/dbml/dbml-import/__tests__/dbml-import.test.ts @@ -5,6 +5,7 @@ import { importDBMLToDiagram, } from '../dbml-import'; import { Parser } from '@dbml/core'; +import { DatabaseType } from '@/lib/domain/database-type'; describe('DBML Import', () => { describe('preprocessDBML', () => { @@ -22,7 +23,7 @@ TableGroup "Test Group" [color: #CA4243] { Table posts { id int }`; - const result = preprocessDBML(dbml); + const { content: result } = preprocessDBML(dbml); expect(result).not.toContain('TableGroup'); expect(result).toContain('Table users'); expect(result).toContain('Table posts'); @@ -37,20 +38,20 @@ Table users { Note note_test { 'This is a note' }`; - const result = preprocessDBML(dbml); + const { content: result } = preprocessDBML(dbml); expect(result).not.toContain('Note'); expect(result).toContain('Table users'); }); - it('should convert array types to text', () => { + it('should remove array syntax while preserving base type', () => { const dbml = ` Table users { tags text[] domains varchar[] }`; - const result = preprocessDBML(dbml); + const { content: result } = preprocessDBML(dbml); expect(result).toContain('tags text'); - expect(result).toContain('domains text'); + expect(result).toContain('domains varchar'); expect(result).not.toContain('[]'); }); @@ -60,7 +61,7 @@ Table users { status enum verification_type enum // comment here }`; - const result = preprocessDBML(dbml); + const { content: result } = preprocessDBML(dbml); expect(result).toContain('status varchar'); expect(result).toContain('verification_type varchar'); expect(result).not.toContain('enum'); @@ -71,7 +72,7 @@ Table users { Table users [headercolor: #24BAB1] { id int }`; - const result = preprocessDBML(dbml); + const { content: result } = preprocessDBML(dbml); expect(result).toContain('Table users {'); expect(result).not.toContain('headercolor'); }); @@ -105,7 +106,9 @@ Note note_test { 'This is a test note' }`; - const diagram = await importDBMLToDiagram(complexDBML); + const diagram = await importDBMLToDiagram(complexDBML, { + databaseType: DatabaseType.POSTGRESQL, + }); expect(diagram.tables).toHaveLength(2); expect(diagram.relationships).toHaveLength(1); @@ -149,7 +152,7 @@ Note note_1750185617764 { }`; // Test that preprocessing handles all issues - const preprocessed = preprocessDBML(problematicDBML); + const { content: preprocessed } = preprocessDBML(problematicDBML); const sanitized = sanitizeDBML(preprocessed); // Should not throw diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts index fdda08e9f..aa82b397d 100644 --- a/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts +++ b/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts @@ -38,7 +38,9 @@ Note test_note { 'This is a test note' }`; - const diagram = await importDBMLToDiagram(dbmlContent); + const diagram = await importDBMLToDiagram(dbmlContent, { + databaseType: DatabaseType.POSTGRESQL, + }); // Verify basic structure expect(diagram).toBeDefined(); @@ -96,7 +98,9 @@ Table products [headercolor: #FF0000] { Ref: products.id < users.favorite_product_id`; - const diagram = await importDBMLToDiagram(dbmlContent); + const diagram = await importDBMLToDiagram(dbmlContent, { + databaseType: DatabaseType.POSTGRESQL, + }); expect(diagram.tables).toHaveLength(2); @@ -119,12 +123,16 @@ Ref: products.id < users.favorite_product_id`; it('should handle empty or invalid DBML gracefully', async () => { // Empty DBML - const emptyDiagram = await importDBMLToDiagram(''); + const emptyDiagram = await importDBMLToDiagram('', { + databaseType: DatabaseType.POSTGRESQL, + }); expect(emptyDiagram.tables).toHaveLength(0); expect(emptyDiagram.relationships).toHaveLength(0); // Only comments - const commentDiagram = await importDBMLToDiagram('// Just a comment'); + const commentDiagram = await importDBMLToDiagram('// Just a comment', { + databaseType: DatabaseType.POSTGRESQL, + }); expect(commentDiagram.tables).toHaveLength(0); expect(commentDiagram.relationships).toHaveLength(0); }); @@ -133,7 +141,9 @@ Ref: products.id < users.favorite_product_id`; const dbmlContent = `Table test { id int [pk] }`; - const diagram = await importDBMLToDiagram(dbmlContent); + const diagram = await importDBMLToDiagram(dbmlContent, { + databaseType: DatabaseType.GENERIC, + }); // Default values expect(diagram.name).toBe('DBML Import'); diff --git a/src/lib/dbml/dbml-import/dbml-import-error.ts b/src/lib/dbml/dbml-import/dbml-import-error.ts index f6286f435..a13ec284d 100644 --- a/src/lib/dbml/dbml-import/dbml-import-error.ts +++ b/src/lib/dbml/dbml-import/dbml-import-error.ts @@ -1,4 +1,6 @@ import type { CompilerError } from '@dbml/core/types/parse/error'; +import type { DatabaseType } from '@/lib/domain/database-type'; +import { databaseSupportsArrays } from '@/lib/domain/database-capabilities'; export interface DBMLError { message: string; @@ -6,8 +8,59 @@ export interface DBMLError { column: number; } +export class DBMLValidationError extends Error { + public readonly dbmlError: DBMLError; + + constructor(message: string, line: number, column: number = 1) { + super(message); + this.name = 'DBMLValidationError'; + this.dbmlError = { message, line, column }; + } +} + +export const getPositionFromIndex = ( + content: string, + matchIndex: number +): { line: number; column: number } => { + const lines = content.substring(0, matchIndex).split('\n'); + return { + line: lines.length, + column: lines[lines.length - 1].length + 1, + }; +}; + +export const validateArrayTypesForDatabase = ( + content: string, + databaseType: DatabaseType +): void => { + // Only validate if database doesn't support arrays + if (databaseSupportsArrays(databaseType)) { + return; + } + + const arrayFieldPattern = /"?(\w+)"?\s+(\w+(?:\(\d+(?:,\s*\d+)?\))?)\[\]/g; + const matches = [...content.matchAll(arrayFieldPattern)]; + + for (const match of matches) { + const fieldName = match[1]; + const dataType = match[2]; + const { line, column } = getPositionFromIndex(content, match.index!); + + throw new DBMLValidationError( + `Array types are not supported for ${databaseType} database. Field "${fieldName}" has array type "${dataType}[]" which is not allowed.`, + line, + column + ); + } +}; + export function parseDBMLError(error: unknown): DBMLError | null { try { + // Check for our custom DBMLValidationError + if (error instanceof DBMLValidationError) { + return error.dbmlError; + } + if (typeof error === 'string') { const parsed = JSON.parse(error); if (parsed.diags?.[0]) { diff --git a/src/lib/dbml/dbml-import/dbml-import.ts b/src/lib/dbml/dbml-import/dbml-import.ts index b9c7b33d0..95c9711f0 100644 --- a/src/lib/dbml/dbml-import/dbml-import.ts +++ b/src/lib/dbml/dbml-import/dbml-import.ts @@ -14,13 +14,21 @@ import { DBCustomTypeKind, type DBCustomType, } from '@/lib/domain/db-custom-type'; +import { validateArrayTypesForDatabase } from './dbml-import-error'; export const defaultDBMLDiagramName = 'DBML Import'; -// Preprocess DBML to handle unsupported features -export const preprocessDBML = (content: string): string => { +interface PreprocessDBMLResult { + content: string; + arrayFields: Map>; +} + +export const preprocessDBML = (content: string): PreprocessDBMLResult => { let processed = content; + // Track array fields found during preprocessing + const arrayFields = new Map>(); + // Remove TableGroup blocks (not supported by parser) processed = processed.replace(/TableGroup\s+[^{]*\{[^}]*\}/gs, ''); @@ -30,8 +38,37 @@ export const preprocessDBML = (content: string): string => { // Don't remove enum definitions - we'll parse them // processed = processed.replace(/enum\s+\w+\s*\{[^}]*\}/gs, ''); - // Handle array types by converting them to text - processed = processed.replace(/(\w+)\[\]/g, 'text'); + // Handle array types by tracking them and converting syntax for DBML parser + // Note: DBML doesn't officially support array syntax, so we convert type[] to type + // but track which fields should be arrays + + // First, find all array field declarations and track them + const tablePattern = + /Table\s+(?:"([^"]+)"\.)?(?:"([^"]+)"|(\w+))\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/gs; + let match; + + while ((match = tablePattern.exec(content)) !== null) { + const schema = match[1] || ''; + const tableName = match[2] || match[3]; + const tableBody = match[4]; + const fullTableName = schema ? `${schema}.${tableName}` : tableName; + + // Find array field declarations within this table + const fieldPattern = /"?(\w+)"?\s+(\w+(?:\([^)]+\))?)\[\]/g; + let fieldMatch; + + while ((fieldMatch = fieldPattern.exec(tableBody)) !== null) { + const fieldName = fieldMatch[1]; + + if (!arrayFields.has(fullTableName)) { + arrayFields.set(fullTableName, new Set()); + } + arrayFields.get(fullTableName)!.add(fieldName); + } + } + + // Now convert array syntax for DBML parser (keep the base type, remove []) + processed = processed.replace(/(\w+(?:\(\d+(?:,\s*\d+)?\))?)\[\]/g, '$1'); // Handle inline enum types without values by converting to varchar processed = processed.replace( @@ -46,7 +83,7 @@ export const preprocessDBML = (content: string): string => { 'Table $1 {' ); - return processed; + return { content: processed, arrayFields }; }; // Simple function to replace Spanish special characters @@ -85,6 +122,7 @@ interface DBMLField { pk?: boolean; not_null?: boolean; increment?: boolean; + isArray?: boolean; characterMaximumLength?: string | null; precision?: number | null; scale?: number | null; @@ -190,8 +228,8 @@ const determineCardinality = ( export const importDBMLToDiagram = async ( dbmlContent: string, - options?: { - databaseType?: DatabaseType; + options: { + databaseType: DatabaseType; } ): Promise => { try { @@ -208,9 +246,13 @@ export const importDBMLToDiagram = async ( }; } + // Validate array types BEFORE preprocessing (preprocessing removes []) + validateArrayTypesForDatabase(dbmlContent, options.databaseType); + const parser = new Parser(); // Preprocess and sanitize DBML content - const preprocessedContent = preprocessDBML(dbmlContent); + const { content: preprocessedContent, arrayFields } = + preprocessDBML(dbmlContent); const sanitizedContent = sanitizeDBML(preprocessedContent); // Handle content that becomes empty after preprocessing @@ -344,11 +386,24 @@ export const importDBMLToDiagram = async ( const rawDefault = String( field.dbdefault.value ); - // Remove ALL quotes (single, double, backticks) to clean the value - // The SQL export layer will handle adding proper quotes when needed defaultValue = rawDefault.replace(/['"`]/g, ''); } + // Check if this field should be an array + const fullTableName = schemaName + ? `${schemaName}.${table.name}` + : table.name; + + let isArray = arrayFields + .get(fullTableName) + ?.has(field.name); + + if (!isArray && schemaName) { + isArray = arrayFields + .get(table.name) + ?.has(field.name); + } + return { name: field.name, type: field.type, @@ -356,6 +411,7 @@ export const importDBMLToDiagram = async ( pk: field.pk, not_null: field.not_null, increment: field.increment, + isArray: isArray || undefined, note: field.note, default: defaultValue, ...getFieldExtraAttributes(field, allEnums), @@ -503,6 +559,8 @@ export const importDBMLToDiagram = async ( characterMaximumLength: field.characterMaximumLength, precision: field.precision, scale: field.scale, + ...(field.increment ? { increment: field.increment } : {}), + ...(field.isArray ? { isArray: field.isArray } : {}), ...(fieldComment ? { comments: fieldComment } : {}), ...(field.default ? { default: field.default } : {}), }; diff --git a/src/lib/dbml/dbml-import/verify-dbml.ts b/src/lib/dbml/dbml-import/verify-dbml.ts index 765b6183e..9bada1837 100644 --- a/src/lib/dbml/dbml-import/verify-dbml.ts +++ b/src/lib/dbml/dbml-import/verify-dbml.ts @@ -1,10 +1,19 @@ import { Parser } from '@dbml/core'; import { preprocessDBML, sanitizeDBML } from './dbml-import'; import type { DBMLError } from './dbml-import-error'; -import { parseDBMLError } from './dbml-import-error'; +import { + parseDBMLError, + validateArrayTypesForDatabase, +} from './dbml-import-error'; +import type { DatabaseType } from '@/lib/domain/database-type'; export const verifyDBML = ( - content: string + content: string, + { + databaseType, + }: { + databaseType: DatabaseType; + } ): | { hasError: true; @@ -16,8 +25,12 @@ export const verifyDBML = ( hasError: false; } => { try { - const preprocessedContent = preprocessDBML(content); + // Validate array types BEFORE preprocessing (preprocessing removes []) + validateArrayTypesForDatabase(content, databaseType); + + const { content: preprocessedContent } = preprocessDBML(content); const sanitizedContent = sanitizeDBML(preprocessedContent); + const parser = new Parser(); parser.parse(sanitizedContent, 'dbmlv2'); } catch (e) { diff --git a/src/lib/domain/database-capabilities.ts b/src/lib/domain/database-capabilities.ts new file mode 100644 index 000000000..8fad91bb8 --- /dev/null +++ b/src/lib/domain/database-capabilities.ts @@ -0,0 +1,57 @@ +import { DatabaseType } from './database-type'; + +export interface DatabaseCapabilities { + supportsArrays?: boolean; + supportsCustomTypes?: boolean; + supportsSchemas?: boolean; + supportsComments?: boolean; +} + +export const DATABASE_CAPABILITIES: Record = + { + [DatabaseType.POSTGRESQL]: { + supportsArrays: true, + supportsCustomTypes: true, + supportsSchemas: true, + supportsComments: true, + }, + [DatabaseType.COCKROACHDB]: { + supportsArrays: true, + supportsSchemas: true, + supportsComments: true, + }, + [DatabaseType.MYSQL]: {}, + [DatabaseType.MARIADB]: {}, + [DatabaseType.SQL_SERVER]: { + supportsSchemas: true, + }, + [DatabaseType.SQLITE]: {}, + [DatabaseType.CLICKHOUSE]: { + supportsSchemas: true, + }, + [DatabaseType.ORACLE]: { + supportsSchemas: true, + supportsComments: true, + }, + [DatabaseType.GENERIC]: {}, + }; + +export const getDatabaseCapabilities = ( + databaseType: DatabaseType +): DatabaseCapabilities => { + return DATABASE_CAPABILITIES[databaseType]; +}; + +export const databaseSupportsArrays = (databaseType: DatabaseType): boolean => { + return getDatabaseCapabilities(databaseType).supportsArrays ?? false; +}; + +export const databaseTypesWithCommentSupport: DatabaseType[] = Object.keys( + DATABASE_CAPABILITIES +).filter( + (dbType) => DATABASE_CAPABILITIES[dbType as DatabaseType].supportsComments +) as DatabaseType[]; + +export const supportsCustomTypes = (databaseType: DatabaseType): boolean => { + return getDatabaseCapabilities(databaseType).supportsCustomTypes ?? false; +}; diff --git a/src/lib/domain/database-type.ts b/src/lib/domain/database-type.ts index 426cd6daa..5168cdfec 100644 --- a/src/lib/domain/database-type.ts +++ b/src/lib/domain/database-type.ts @@ -9,9 +9,3 @@ export enum DatabaseType { COCKROACHDB = 'cockroachdb', ORACLE = 'oracle', } - -export const databaseTypesWithCommentSupport: DatabaseType[] = [ - DatabaseType.POSTGRESQL, - DatabaseType.COCKROACHDB, - DatabaseType.ORACLE, -]; diff --git a/src/lib/domain/db-field.ts b/src/lib/domain/db-field.ts index 042530e3d..3f7ef1533 100644 --- a/src/lib/domain/db-field.ts +++ b/src/lib/domain/db-field.ts @@ -2,9 +2,10 @@ import { z } from 'zod'; import { dataTypeSchema, findDataTypeDataById, + supportsArrayDataType, type DataType, } from '../data/data-types/data-types'; -import type { DatabaseType } from './database-type'; +import { DatabaseType } from './database-type'; export interface DBField { id: string; @@ -14,6 +15,7 @@ export interface DBField { unique: boolean; nullable: boolean; increment?: boolean | null; + isArray?: boolean | null; createdAt: number; characterMaximumLength?: string | null; precision?: number | null; @@ -31,6 +33,7 @@ export const dbFieldSchema: z.ZodType = z.object({ unique: z.boolean(), nullable: z.boolean(), increment: z.boolean().or(z.null()).optional(), + isArray: z.boolean().or(z.null()).optional(), createdAt: z.number(), characterMaximumLength: z.string().or(z.null()).optional(), precision: z.number().or(z.null()).optional(), @@ -52,11 +55,26 @@ export const generateDBFieldSuffix = ( typeId?: string; } = {} ): string => { + let suffix = ''; + if (databaseType && forceExtended && typeId) { - return generateExtendedSuffix(field, databaseType, typeId); + suffix = generateExtendedSuffix(field, databaseType, typeId); + } else { + suffix = generateStandardSuffix(field); + } + + // Add array notation if field is an array + if ( + field.isArray && + supportsArrayDataType( + typeId ?? field.type.id, + databaseType ?? DatabaseType.GENERIC + ) + ) { + suffix += '[]'; } - return generateStandardSuffix(field); + return suffix; }; const generateExtendedSuffix = ( diff --git a/src/lib/domain/db-schema.ts b/src/lib/domain/db-schema.ts index e1fe87f94..3cafa8956 100644 --- a/src/lib/domain/db-schema.ts +++ b/src/lib/domain/db-schema.ts @@ -1,4 +1,5 @@ -import { DatabaseType } from './database-type'; +import { DATABASE_CAPABILITIES } from './database-capabilities'; +import type { DatabaseType } from './database-type'; export interface DBSchema { id: string; @@ -18,10 +19,8 @@ export const schemaNameToDomainSchemaName = ( ? undefined : schema?.trim(); -export const databasesWithSchemas: DatabaseType[] = [ - DatabaseType.POSTGRESQL, - DatabaseType.SQL_SERVER, - DatabaseType.CLICKHOUSE, - DatabaseType.COCKROACHDB, - DatabaseType.ORACLE, -]; +export const databasesWithSchemas: DatabaseType[] = Object.keys( + DATABASE_CAPABILITIES +).filter( + (dbType) => DATABASE_CAPABILITIES[dbType as DatabaseType].supportsSchemas +) as DatabaseType[]; diff --git a/src/lib/domain/diff/diff-check/diff-check.ts b/src/lib/domain/diff/diff-check/diff-check.ts index 62c949e44..e82658ca4 100644 --- a/src/lib/domain/diff/diff-check/diff-check.ts +++ b/src/lib/domain/diff/diff-check/diff-check.ts @@ -28,6 +28,16 @@ export function getDiffMapKey({ : `${diffObject}-${objectId}`; } +const isOneOfDefined = ( + ...values: (string | number | boolean | undefined | null)[] +): boolean => { + return values.some((value) => value !== undefined && value !== null); +}; + +const normalizeBoolean = (value: boolean | undefined | null): boolean => { + return value === true; +}; + export interface GenerateDiffOptions { includeTables?: boolean; includeFields?: boolean; @@ -552,6 +562,8 @@ function compareFieldProperties({ 'characterMaximumLength', 'scale', 'precision', + 'increment', + 'isArray', ]; const changedAttributes: FieldDiffAttribute[] = []; @@ -620,6 +632,24 @@ function compareFieldProperties({ changedAttributes.push('precision'); } + if ( + attributesToCheck.includes('increment') && + isOneOfDefined(newField.increment, oldField.increment) && + normalizeBoolean(oldField.increment) !== + normalizeBoolean(newField.increment) + ) { + changedAttributes.push('increment'); + } + + if ( + attributesToCheck.includes('isArray') && + isOneOfDefined(newField.isArray, oldField.isArray) && + normalizeBoolean(oldField.isArray) !== + normalizeBoolean(newField.isArray) + ) { + changedAttributes.push('isArray'); + } + if (changedAttributes.length > 0) { for (const attribute of changedAttributes) { diffMap.set( diff --git a/src/lib/domain/diff/field-diff.ts b/src/lib/domain/diff/field-diff.ts index b7fc20e1d..93d4ad23e 100644 --- a/src/lib/domain/diff/field-diff.ts +++ b/src/lib/domain/diff/field-diff.ts @@ -15,7 +15,9 @@ export type FieldDiffAttribute = | 'comments' | 'characterMaximumLength' | 'precision' - | 'scale'; + | 'scale' + | 'increment' + | 'isArray'; export const fieldDiffAttributeSchema: z.ZodType = z.union([ z.literal('name'), diff --git a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx index 3a75054d9..69778c22d 100644 --- a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx +++ b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { KeyRound, Trash2 } from 'lucide-react'; import { Input } from '@/components/input/input'; import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field'; -import type { DBTable } from '@/lib/domain'; +import type { DatabaseType, DBTable } from '@/lib/domain'; import { useUpdateTableField } from '@/hooks/use-update-table-field'; import { Tooltip, @@ -18,10 +18,11 @@ export interface TableEditModeFieldProps { table: DBTable; field: DBField; focused?: boolean; + databaseType: DatabaseType; } export const TableEditModeField: React.FC = React.memo( - ({ table, field, focused = false }) => { + ({ table, field, focused = false, databaseType }) => { const { t } = useTranslation(); const [showHighlight, setShowHighlight] = React.useState(false); @@ -102,7 +103,9 @@ export const TableEditModeField: React.FC = React.memo( 'side_panel.tables_section.table.field_type' )} value={field.type.id} - valueSuffix={generateDBFieldSuffix(field)} + valueSuffix={generateDBFieldSuffix(field, { + databaseType, + })} optionSuffix={(option) => generateFieldSuffix(option.value) } @@ -119,9 +122,9 @@ export const TableEditModeField: React.FC = React.memo( {field.type.name} - {field.characterMaximumLength - ? `(${field.characterMaximumLength})` - : ''} + {generateDBFieldSuffix(field, { + databaseType, + })}
diff --git a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx index c62f4ce31..080730070 100644 --- a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx +++ b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode.tsx @@ -300,6 +300,7 @@ export const TableEditMode: React.FC = React.memo( table={table} field={field} focused={focusFieldId === field.id} + databaseType={databaseType} />
))} diff --git a/src/pages/editor-page/canvas/table-node/table-node-field.tsx b/src/pages/editor-page/canvas/table-node/table-node-field.tsx index 8d54ee96b..d9f420b4d 100644 --- a/src/pages/editor-page/canvas/table-node/table-node-field.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node-field.tsx @@ -67,6 +67,7 @@ const arePropsEqual = ( nextProps.field.characterMaximumLength && prevProps.field.precision === nextProps.field.precision && prevProps.field.scale === nextProps.field.scale && + prevProps.field.isArray === nextProps.field.isArray && prevProps.focused === nextProps.focused && prevProps.highlighted === nextProps.highlighted && prevProps.visible === nextProps.visible && @@ -77,7 +78,8 @@ const arePropsEqual = ( export const TableNodeField: React.FC = React.memo( ({ field, focused, tableNodeId, highlighted, visible, isConnectable }) => { - const { relationships, readonly, highlightedCustomType } = useChartDB(); + const { relationships, readonly, highlightedCustomType, databaseType } = + useChartDB(); const updateNodeInternals = useUpdateNodeInternals(); const connection = useConnection(); @@ -152,6 +154,7 @@ export const TableNodeField: React.FC = React.memo( getFieldNewCharacterMaximumLength, getFieldNewPrecision, getFieldNewScale, + getFieldNewIsArray, checkIfFieldHasChange, isSummaryOnly, } = useDiff(); @@ -170,6 +173,7 @@ export const TableNodeField: React.FC = React.memo( fieldDiffChangedPrimaryKey: ReturnType< typeof getFieldNewPrimaryKey >; + fieldDiffChangedIsArray: ReturnType; isDiffFieldChanged: boolean; }>({ isDiffFieldRemoved: false, @@ -181,6 +185,7 @@ export const TableNodeField: React.FC = React.memo( fieldDiffChangedScale: null, fieldDiffChangedPrecision: null, fieldDiffChangedPrimaryKey: null, + fieldDiffChangedIsArray: null, isDiffFieldChanged: false, }); @@ -214,6 +219,9 @@ export const TableNodeField: React.FC = React.memo( fieldDiffChangedPrecision: getFieldNewPrecision({ fieldId: field.id, }), + fieldDiffChangedIsArray: getFieldNewIsArray({ + fieldId: field.id, + }), isDiffFieldChanged: checkIfFieldHasChange({ fieldId: field.id, tableId: tableNodeId, @@ -232,6 +240,7 @@ export const TableNodeField: React.FC = React.memo( getFieldNewCharacterMaximumLength, getFieldNewPrecision, getFieldNewScale, + getFieldNewIsArray, field.id, tableNodeId, ]); @@ -247,8 +256,23 @@ export const TableNodeField: React.FC = React.memo( fieldDiffChangedCharacterMaximumLength, fieldDiffChangedScale, fieldDiffChangedPrecision, + fieldDiffChangedIsArray, } = diffState; + const isFieldAttributeChanged = useMemo(() => { + return ( + fieldDiffChangedCharacterMaximumLength || + fieldDiffChangedScale || + fieldDiffChangedPrecision || + fieldDiffChangedIsArray + ); + }, [ + fieldDiffChangedCharacterMaximumLength, + fieldDiffChangedScale, + fieldDiffChangedPrecision, + fieldDiffChangedIsArray, + ]); + const isCustomTypeHighlighted = useMemo(() => { if (!highlightedCustomType) return false; return field.type.name === highlightedCustomType.name; @@ -342,17 +366,14 @@ export const TableNodeField: React.FC = React.memo( )}
{isDiffFieldRemoved ? ( - + ) : isDiffNewField ? ( - + ) : isDiffFieldChanged && !isSummaryOnly ? ( ) : null} @@ -392,13 +413,17 @@ export const TableNodeField: React.FC = React.memo( ) : null}
-
+
{(field.primaryKey && !fieldDiffChangedPrimaryKey?.old) || fieldDiffChangedPrimaryKey?.new ? (
= React.memo(
) : null} -
= React.memo( )} > - {fieldDiffChangedType ? ( - <> - + { + // fieldDiffChangedType ? ( + // <> + // + // { + // fieldDiffChangedType.old.name.split( + // ' ' + // )[0] + // } + // {' '} + // { + // fieldDiffChangedType.new.name.split( + // ' ' + // )[0] + // } + // + // ) : + isFieldAttributeChanged || + fieldDiffChangedType ? ( + <> + + { + ( + fieldDiffChangedType?.old + ?.name ?? + field.type.name + ).split(' ')[0] + } + {showFieldAttributes + ? generateDBFieldSuffix( + { + ...field, + ...{ + precision: + fieldDiffChangedPrecision?.old ?? + field.precision, + scale: + fieldDiffChangedScale?.old ?? + field.scale, + characterMaximumLength: + fieldDiffChangedCharacterMaximumLength?.old ?? + field.characterMaximumLength, + isArray: + fieldDiffChangedIsArray?.old ?? + field.isArray, + }, + }, + { + databaseType, + } + ) + : field.isArray + ? '[]' + : ''} + {' '} { - fieldDiffChangedType.old.name.split( - ' ' - )[0] + ( + fieldDiffChangedType?.new + ?.name ?? field.type.name + ).split(' ')[0] } - {' '} - { - fieldDiffChangedType.new.name.split( - ' ' - )[0] - } - - ) : ( - `${field.type.name.split(' ')[0]}${ - showFieldAttributes - ? generateDBFieldSuffix({ - ...field, - ...{ - precision: - fieldDiffChangedPrecision?.new ?? - field.precision, - scale: - fieldDiffChangedScale?.new ?? - field.scale, - characterMaximumLength: - fieldDiffChangedCharacterMaximumLength?.new ?? - field.characterMaximumLength, - }, - }) - : '' - }` - )} + {showFieldAttributes + ? generateDBFieldSuffix( + { + ...field, + ...{ + precision: + fieldDiffChangedPrecision?.new ?? + field.precision, + scale: + fieldDiffChangedScale?.new ?? + field.scale, + characterMaximumLength: + fieldDiffChangedCharacterMaximumLength?.new ?? + field.characterMaximumLength, + isArray: + fieldDiffChangedIsArray?.new ?? + field.isArray, + }, + }, + { + databaseType, + } + ) + : (fieldDiffChangedIsArray?.new ?? + field.isArray) + ? '[]' + : ''} + + ) : ( + `${field.type.name.split(' ')[0]}${ + showFieldAttributes + ? generateDBFieldSuffix(field, { + databaseType, + }) + : field.isArray + ? '[]' + : '' + }` + ) + } {fieldDiffChangedNullable ? ( fieldDiffChangedNullable.new ? ( ? @@ -485,21 +573,21 @@ export const TableNodeField: React.FC = React.memo( )}
- {readonly ? null : ( -
- -
- )}
+ {readonly ? null : ( +
+ +
+ )}
); }, diff --git a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx index 0151170c3..124dd4454 100644 --- a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx +++ b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx @@ -8,6 +8,7 @@ import type { FieldAttributeRange } from '@/lib/data/data-types/data-types'; import { findDataTypeDataById, supportsAutoIncrementDataType, + supportsArrayDataType, } from '@/lib/data/data-types/data-types'; import { Popover, @@ -89,6 +90,7 @@ export const TableFieldPopover: React.FC = ({ unique: localField.unique, default: localField.default, increment: localField.increment, + isArray: localField.isArray, }); } prevFieldRef.current = localField; @@ -104,6 +106,11 @@ export const TableFieldPopover: React.FC = ({ [field.type.name] ); + const supportsArray = useMemo( + () => supportsArrayDataType(field.type.name, databaseType), + [field.type.name, databaseType] + ); + return ( = ({ />
) : null} + {supportsArray ? ( +
+ + + setLocalField((current) => ({ + ...current, + isArray: !!value, + })) + } + /> +
+ ) : null}
From ccb29e0a574dfa4cfdf0ebf242a4c4aaa48cc37b Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Mon, 20 Oct 2025 17:12:15 +0300 Subject: [PATCH 036/156] fix: resolve canvas filter tree state issues (#953) --- src/components/tree-view/tree-view.tsx | 13 +++++++- src/components/tree-view/use-tree.ts | 19 ++++++++--- .../canvas/canvas-filter/canvas-filter.tsx | 32 +++++++++++++++---- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/components/tree-view/tree-view.tsx b/src/components/tree-view/tree-view.tsx index 9424be2d5..6bdb181af 100644 --- a/src/components/tree-view/tree-view.tsx +++ b/src/components/tree-view/tree-view.tsx @@ -42,6 +42,7 @@ interface TreeViewProps< renderHoverComponent?: (node: TreeNode) => ReactNode; renderActionsComponent?: (node: TreeNode) => ReactNode; loadingNodeIds?: string[]; + disableCache?: boolean; } export function TreeView< @@ -62,12 +63,14 @@ export function TreeView< renderHoverComponent, renderActionsComponent, loadingNodeIds, + disableCache = false, }: TreeViewProps) { const { expanded, loading, loadedChildren, hasMoreChildren, toggleNode } = useTree({ fetchChildren, expanded: expandedProp, setExpanded: setExpandedProp, + disableCache, }); const [selectedIdInternal, setSelectedIdInternal] = React.useState< string | undefined @@ -145,6 +148,7 @@ export function TreeView< renderHoverComponent={renderHoverComponent} renderActionsComponent={renderActionsComponent} loadingNodeIds={loadingNodeIds} + disableCache={disableCache} /> ))}
@@ -179,6 +183,7 @@ interface TreeNodeProps< renderHoverComponent?: (node: TreeNode) => ReactNode; renderActionsComponent?: (node: TreeNode) => ReactNode; loadingNodeIds?: string[]; + disableCache?: boolean; } function TreeNode>({ @@ -201,11 +206,16 @@ function TreeNode>({ renderHoverComponent, renderActionsComponent, loadingNodeIds, + disableCache = false, }: TreeNodeProps) { const [isHovered, setIsHovered] = useState(false); const isExpanded = expanded[node.id]; const isLoading = loading[node.id]; - const children = loadedChildren[node.id] || node.children; + // If cache is disabled, always use fresh node.children + // Otherwise, use cached loadedChildren if available (for async fetched data) + const children = disableCache + ? node.children + : node.children || loadedChildren[node.id]; const isSelected = selectedId === node.id; const IconComponent = @@ -423,6 +433,7 @@ function TreeNode>({ renderHoverComponent={renderHoverComponent} renderActionsComponent={renderActionsComponent} loadingNodeIds={loadingNodeIds} + disableCache={disableCache} /> ))} {isLoading ? ( diff --git a/src/components/tree-view/use-tree.ts b/src/components/tree-view/use-tree.ts index 5a3c94761..19e34da95 100644 --- a/src/components/tree-view/use-tree.ts +++ b/src/components/tree-view/use-tree.ts @@ -28,10 +28,12 @@ export function useTree< fetchChildren, expanded: expandedProp, setExpanded: setExpandedProp, + disableCache = false, }: { fetchChildren?: FetchChildrenFunction; expanded?: ExpandedState; setExpanded?: Dispatch>; + disableCache?: boolean; }) { const [expandedInternal, setExpandedInternal] = useState({}); @@ -89,8 +91,8 @@ export function useTree< // Get any previously fetched children const previouslyFetchedChildren = loadedChildren[nodeId] || []; - // If we have static children, merge them with any previously fetched children - if (staticChildren?.length) { + // Only cache if caching is enabled + if (!disableCache && staticChildren?.length) { const mergedChildren = mergeChildren( staticChildren, previouslyFetchedChildren @@ -110,8 +112,8 @@ export function useTree< // Set expanded state immediately to show static/previously fetched children setExpanded((prev) => ({ ...prev, [nodeId]: true })); - // If we haven't loaded dynamic children yet - if (!previouslyFetchedChildren.length) { + // If we haven't loaded dynamic children yet and cache is enabled + if (!disableCache && !previouslyFetchedChildren.length) { setLoading((prev) => ({ ...prev, [nodeId]: true })); try { const fetchedChildren = await fetchChildren?.( @@ -140,7 +142,14 @@ export function useTree< } } }, - [expanded, loadedChildren, fetchChildren, mergeChildren, setExpanded] + [ + expanded, + loadedChildren, + fetchChildren, + mergeChildren, + setExpanded, + disableCache, + ] ); return { diff --git a/src/pages/editor-page/canvas/canvas-filter/canvas-filter.tsx b/src/pages/editor-page/canvas/canvas-filter/canvas-filter.tsx index bd67b224a..ca3d9723d 100644 --- a/src/pages/editor-page/canvas/canvas-filter/canvas-filter.tsx +++ b/src/pages/editor-page/canvas/canvas-filter/canvas-filter.tsx @@ -101,13 +101,32 @@ export const CanvasFilter: React.FC = ({ onClose }) => { areas, ]); - // Initialize expanded state with all schemas expanded - useMemo(() => { - const initialExpanded: Record = {}; - treeData.forEach((node) => { - initialExpanded[node.id] = true; + // Sync expanded state with tree data changes - only expand NEW nodes + useEffect(() => { + setExpanded((prev) => { + const currentNodeIds = new Set(treeData.map((n) => n.id)); + let hasChanges = false; + const newExpanded: Record = { ...prev }; + + // Add any new nodes with expanded=true (preserve existing state) + treeData.forEach((node) => { + if (!(node.id in prev)) { + newExpanded[node.id] = true; + hasChanges = true; + } + }); + + // Remove nodes that no longer exist (cleanup) + Object.keys(prev).forEach((id) => { + if (!currentNodeIds.has(id)) { + delete newExpanded[id]; + hasChanges = true; + } + }); + + // Only update state if something actually changed (performance) + return hasChanges ? newExpanded : prev; }); - setExpanded(initialExpanded); }, [treeData]); // Filter tree data based on search query @@ -317,6 +336,7 @@ export const CanvasFilter: React.FC = ({ onClose }) => { expanded={expanded} setExpanded={setExpanded} className="py-2" + disableCache={true} />
From 2c4b344efb24041e7f607fc6124e109b69aaa457 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Tue, 21 Oct 2025 12:31:32 +0300 Subject: [PATCH 037/156] fix: resolve dbml increment & nullable attributes issue (#954) * fix: resolve dbml increment attribute * fix nullable * fix --- src/hooks/use-update-table-field.ts | 20 ++++- src/lib/data/data-types/data-types.ts | 12 +++ src/lib/data/sql-export/export-sql-script.ts | 32 +++++++- .../dbml/dbml-export/__tests__/cases/4.dbml | 2 +- .../dbml/dbml-export/__tests__/cases/6.dbml | 14 ++++ .../dbml/dbml-export/__tests__/cases/6.json | 1 + .../__tests__/export-sql-dbml-cases.test.ts | 8 ++ src/lib/dbml/dbml-export/dbml-export.ts | 51 ++++++++++++ .../__tests__/dbml-import-cases.test.ts | 81 +++++++++++++++++++ src/lib/dbml/dbml-import/dbml-import.ts | 10 ++- .../table-edit-mode/table-edit-mode-field.tsx | 4 + .../table-field-modal/table-field-modal.tsx | 21 ++++- .../table-field/table-field.tsx | 5 +- 13 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/6.dbml create mode 100644 src/lib/dbml/dbml-export/__tests__/cases/6.json diff --git a/src/hooks/use-update-table-field.ts b/src/hooks/use-update-table-field.ts index 72225c080..f25f92b87 100644 --- a/src/hooks/use-update-table-field.ts +++ b/src/hooks/use-update-table-field.ts @@ -10,6 +10,8 @@ import { dataTypeDataToDataType, sortedDataTypeMap, supportsArrayDataType, + autoIncrementAlwaysOn, + requiresNotNull, } from '@/lib/data/data-types/data-types'; import { generateDBFieldSuffix } from '@/lib/domain/db-field'; import type { DataTypeData } from '@/lib/data/data-types/data-types'; @@ -224,12 +226,17 @@ export const useUpdateTableField = ( } } + const newTypeName = dataType?.name ?? (value as string); + const typeRequiresNotNull = requiresNotNull(newTypeName); + const shouldForceIncrement = autoIncrementAlwaysOn(newTypeName); + updateField(table.id, field.id, { characterMaximumLength, precision, scale, isArray, - increment: undefined, + ...(typeRequiresNotNull ? { nullable: false } : {}), + increment: shouldForceIncrement ? true : undefined, default: undefined, type: dataTypeDataToDataType( dataType ?? { @@ -267,9 +274,16 @@ export const useUpdateTableField = ( const debouncedNullableUpdate = useDebounce( useCallback( (value: boolean) => { - updateField(table.id, field.id, { nullable: value }); + const updates: Partial = { nullable: value }; + + // If setting to nullable, clear increment (auto-increment requires NOT NULL) + if (value && field.increment) { + updates.increment = undefined; + } + + updateField(table.id, field.id, updates); }, - [updateField, table.id, field.id] + [updateField, table.id, field.id, field.increment] ), 100 // 100ms debounce for toggle ); diff --git a/src/lib/data/data-types/data-types.ts b/src/lib/data/data-types/data-types.ts index f44b1910a..701da23bc 100644 --- a/src/lib/data/data-types/data-types.ts +++ b/src/lib/data/data-types/data-types.ts @@ -167,6 +167,18 @@ export const supportsAutoIncrementDataType = ( ].includes(dataTypeName.toLocaleLowerCase()); }; +export const autoIncrementAlwaysOn = (dataTypeName: string): boolean => { + return ['serial', 'bigserial', 'smallserial'].includes( + dataTypeName.toLowerCase() + ); +}; + +export const requiresNotNull = (dataTypeName: string): boolean => { + return ['serial', 'bigserial', 'smallserial'].includes( + dataTypeName.toLowerCase() + ); +}; + const ARRAY_INCOMPATIBLE_TYPES = [ 'serial', 'bigserial', diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index 39f9ef828..cd019dbd7 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -395,9 +395,26 @@ export const exportBaseSQL = ({ sqlScript += ` UNIQUE`; } - // Handle AUTO INCREMENT - add as a comment for AI to process + // Handle AUTO INCREMENT if (field.increment) { - sqlScript += ` /* AUTO_INCREMENT */`; + if (isDBMLFlow) { + // For DBML flow, generate proper database-specific syntax + if ( + targetDatabaseType === DatabaseType.MYSQL || + targetDatabaseType === DatabaseType.MARIADB + ) { + sqlScript += ` AUTO_INCREMENT`; + } else if (targetDatabaseType === DatabaseType.SQL_SERVER) { + sqlScript += ` IDENTITY(1,1)`; + } else if (targetDatabaseType === DatabaseType.SQLITE) { + // SQLite AUTOINCREMENT only works with INTEGER PRIMARY KEY + // Will be handled when PRIMARY KEY is added + } + // PostgreSQL/CockroachDB: increment attribute added by restoreIncrementAttribute in DBML export + } else { + // For non-DBML flow, add as a comment for AI to process + sqlScript += ` /* AUTO_INCREMENT */`; + } } // Handle DEFAULT value @@ -450,6 +467,17 @@ export const exportBaseSQL = ({ const pkIndex = table.indexes.find((idx) => idx.isPrimaryKey); if (field.primaryKey && !hasCompositePrimaryKey && !pkIndex?.name) { sqlScript += ' PRIMARY KEY'; + + // For SQLite with DBML flow, add AUTOINCREMENT after PRIMARY KEY + if ( + isDBMLFlow && + field.increment && + targetDatabaseType === DatabaseType.SQLITE && + (typeName.toLowerCase() === 'integer' || + typeName.toLowerCase() === 'int') + ) { + sqlScript += ' AUTOINCREMENT'; + } } // Add a comma after each field except the last one (or before PK constraint) diff --git a/src/lib/dbml/dbml-export/__tests__/cases/4.dbml b/src/lib/dbml/dbml-export/__tests__/cases/4.dbml index 3b2bcd981..084bd53bd 100644 --- a/src/lib/dbml/dbml-export/__tests__/cases/4.dbml +++ b/src/lib/dbml/dbml-export/__tests__/cases/4.dbml @@ -1,5 +1,5 @@ Table "public"."orders" { - "order_id" integer [pk, not null] + "order_id" integer [pk, not null, increment] "customer_id" integer [not null] "order_date" date [not null, default: `CURRENT_DATE`] "total_amount" numeric [not null, default: 0] diff --git a/src/lib/dbml/dbml-export/__tests__/cases/6.dbml b/src/lib/dbml/dbml-export/__tests__/cases/6.dbml new file mode 100644 index 000000000..5fd1cb90a --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/6.dbml @@ -0,0 +1,14 @@ +Table "users" { + "id" integer [pk, not null, increment] + "username" varchar(100) [unique, not null] + "email" varchar(255) [not null] +} + +Table "posts" { + "post_id" bigint [pk, not null, increment] + "user_id" integer [not null] + "title" varchar(200) [not null] + "order_num" integer [not null, increment] +} + +Ref "fk_0_fk_posts_users":"users"."id" < "posts"."user_id" diff --git a/src/lib/dbml/dbml-export/__tests__/cases/6.json b/src/lib/dbml/dbml-export/__tests__/cases/6.json new file mode 100644 index 000000000..189f499bf --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/6.json @@ -0,0 +1 @@ +{"id":"test_auto_increment","name":"Auto Increment Test (mysql)","createdAt":"2025-01-20T00:00:00.000Z","updatedAt":"2025-01-20T00:00:00.000Z","databaseType":"mysql","tables":[{"id":"table1","name":"users","order":1,"fields":[{"id":"field1","name":"id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field2","name":"username","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":true,"default":"","increment":false,"characterMaximumLength":"100","createdAt":1705708800000},{"id":"field3","name":"email","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"255","createdAt":1705708800000}],"indexes":[],"x":100,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000},{"id":"table2","name":"posts","order":2,"fields":[{"id":"field4","name":"post_id","type":{"id":"bigint","name":"bigint"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field5","name":"user_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"createdAt":1705708800000},{"id":"field6","name":"title","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"200","createdAt":1705708800000},{"id":"field7","name":"order_num","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":true,"createdAt":1705708800000}],"indexes":[],"x":300,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000}],"relationships":[{"id":"rel1","name":"fk_posts_users","sourceTableId":"table2","targetTableId":"table1","sourceFieldId":"field5","targetFieldId":"field1","type":"one_to_many","sourceCardinality":"many","targetCardinality":"one","createdAt":1705708800000}],"dependencies":[],"storageMode":"project","areas":[],"creationMethod":"manual","customTypes":[]} \ No newline at end of file diff --git a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts index f1fd54ca7..0d9c3a5b9 100644 --- a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts +++ b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts @@ -66,4 +66,12 @@ describe('DBML Export cases', () => { it('should handle case 5 diagram', { timeout: 30000 }, async () => { testCase('5'); }); + + it( + 'should handle case 6 diagram - auto increment', + { timeout: 30000 }, + async () => { + testCase('6'); + } + ); }); diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index 4185bfa14..5819a718d 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -583,6 +583,54 @@ const fixMultilineTableNames = (dbml: string): string => { ); }; +// Restore increment attribute for auto-incrementing fields +const restoreIncrementAttribute = (dbml: string, tables: DBTable[]): string => { + if (!tables || tables.length === 0) return dbml; + + let result = dbml; + + tables.forEach((table) => { + // Find fields with increment=true + const incrementFields = table.fields.filter((f) => f.increment); + + incrementFields.forEach((field) => { + // Build the table identifier pattern + const tableIdentifier = table.schema + ? `"${table.schema.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\."${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"` + : `"${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`; + + // Escape field name for regex + const escapedFieldName = field.name.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&' + ); + + // Pattern to match the field line with existing attributes in brackets + // Matches: "field_name" type [existing, attributes] + const fieldPattern = new RegExp( + `(Table ${tableIdentifier} \\{[^}]*?^\\s*"${escapedFieldName}"[^\\[\\n]+)(\\[[^\\]]*\\])`, + 'gms' + ); + + result = result.replace( + fieldPattern, + (match, fieldPart, brackets) => { + // Check if increment already exists + if (brackets.includes('increment')) { + return match; + } + + // Add increment to the attributes + const newBrackets = brackets.replace(']', ', increment]'); + return fieldPart + newBrackets; + } + ); + }); + }); + + return result; +}; + // Restore composite primary key names in the DBML const restoreCompositePKNames = (dbml: string, tables: DBTable[]): string => { if (!tables || tables.length === 0) return dbml; @@ -888,6 +936,9 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { // Restore composite primary key names standard = restoreCompositePKNames(standard, uniqueTables); + // Restore increment attribute for auto-incrementing fields + standard = restoreIncrementAttribute(standard, uniqueTables); + // Prepend Enum DBML to the standard output if (enumsDBML) { standard = enumsDBML + '\n\n' + standard; diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts index 170fc02b4..28cfd3b4f 100644 --- a/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts +++ b/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts @@ -342,4 +342,85 @@ describe('DBML Import cases', () => { ); expect(createdAtField?.default).toBe('now()'); }); + + it('should handle auto-increment fields correctly', async () => { + const dbmlContent = `Table "public"."table_1" { + "id" integer [pk, not null, increment] + "field_2" bigint [increment] + "field_3" serial [increment] + "field_4" varchar(100) [not null] +}`; + + const result = await importDBMLToDiagram(dbmlContent, { + databaseType: DatabaseType.POSTGRESQL, + }); + + expect(result.tables).toHaveLength(1); + const table = result.tables![0]; + expect(table.name).toBe('table_1'); + expect(table.fields).toHaveLength(4); + + // field with [pk, not null, increment] - should be not null and increment + const idField = table.fields.find((f) => f.name === 'id'); + expect(idField?.increment).toBe(true); + expect(idField?.nullable).toBe(false); + expect(idField?.primaryKey).toBe(true); + + // field with [increment] only - should be not null and increment + // (auto-increment requires NOT NULL even if not explicitly stated) + const field2 = table.fields.find((f) => f.name === 'field_2'); + expect(field2?.increment).toBe(true); + expect(field2?.nullable).toBe(false); // CRITICAL: must be false! + + // SERIAL type with [increment] - should be not null and increment + const field3 = table.fields.find((f) => f.name === 'field_3'); + expect(field3?.increment).toBe(true); + expect(field3?.nullable).toBe(false); + expect(field3?.type?.name).toBe('serial'); + + // Regular field with [not null] - should be not null, no increment + const field4 = table.fields.find((f) => f.name === 'field_4'); + expect(field4?.increment).toBeUndefined(); + expect(field4?.nullable).toBe(false); + }); + + it('should handle SERIAL types without increment attribute', async () => { + const dbmlContent = `Table "public"."test_table" { + "id" serial [pk] + "counter" bigserial + "small_counter" smallserial + "regular" integer +}`; + + const result = await importDBMLToDiagram(dbmlContent, { + databaseType: DatabaseType.POSTGRESQL, + }); + + expect(result.tables).toHaveLength(1); + const table = result.tables![0]; + expect(table.fields).toHaveLength(4); + + // SERIAL type without [increment] - should STILL be not null (type requires it) + const idField = table.fields.find((f) => f.name === 'id'); + expect(idField?.type?.name).toBe('serial'); + expect(idField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL + expect(idField?.primaryKey).toBe(true); + + // BIGSERIAL without [increment] - should be not null + const counterField = table.fields.find((f) => f.name === 'counter'); + expect(counterField?.type?.name).toBe('bigserial'); + expect(counterField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL + + // SMALLSERIAL without [increment] - should be not null + const smallCounterField = table.fields.find( + (f) => f.name === 'small_counter' + ); + expect(smallCounterField?.type?.name).toBe('smallserial'); + expect(smallCounterField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL + + // Regular INTEGER - should be nullable by default + const regularField = table.fields.find((f) => f.name === 'regular'); + expect(regularField?.type?.name).toBe('integer'); + expect(regularField?.nullable).toBe(true); // No NOT NULL constraint + }); }); diff --git a/src/lib/dbml/dbml-import/dbml-import.ts b/src/lib/dbml/dbml-import/dbml-import.ts index 95c9711f0..c82125a99 100644 --- a/src/lib/dbml/dbml-import/dbml-import.ts +++ b/src/lib/dbml/dbml-import/dbml-import.ts @@ -5,7 +5,10 @@ import type { DBTable } from '@/lib/domain/db-table'; import type { Cardinality, DBRelationship } from '@/lib/domain/db-relationship'; import type { DBField } from '@/lib/domain/db-field'; import type { DataTypeData } from '@/lib/data/data-types/data-types'; -import { findDataTypeDataById } from '@/lib/data/data-types/data-types'; +import { + findDataTypeDataById, + requiresNotNull, +} from '@/lib/data/data-types/data-types'; import { defaultTableColor } from '@/lib/colors'; import { DatabaseType } from '@/lib/domain/database-type'; import type Field from '@dbml/core/types/model_structure/field'; @@ -552,7 +555,10 @@ export const importDBMLToDiagram = async ( ...options, enums: extractedData.enums, }), - nullable: !field.not_null, + nullable: + field.increment || requiresNotNull(field.type.type_name) + ? false + : !field.not_null, primaryKey: field.pk || false, unique: field.unique || field.pk || false, // Primary keys are always unique createdAt: Date.now(), diff --git a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx index 69778c22d..da46531ea 100644 --- a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx +++ b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'; import { SelectBox } from '@/components/select-box/select-box'; import { cn } from '@/lib/utils'; import { TableFieldToggle } from './table-field-toggle'; +import { requiresNotNull } from '@/lib/data/data-types/data-types'; export interface TableEditModeFieldProps { table: DBTable; @@ -41,6 +42,8 @@ export const TableEditModeField: React.FC = React.memo( const inputRef = React.useRef(null); + const typeRequiresNotNull = requiresNotNull(field.type.name); + // Animate the highlight after mount if focused useEffect(() => { if (focused) { @@ -135,6 +138,7 @@ export const TableEditModeField: React.FC = React.memo( N diff --git a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx index 124dd4454..d60318cc0 100644 --- a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx +++ b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx @@ -9,6 +9,7 @@ import { findDataTypeDataById, supportsAutoIncrementDataType, supportsArrayDataType, + autoIncrementAlwaysOn, } from '@/lib/data/data-types/data-types'; import { Popover, @@ -111,6 +112,18 @@ export const TableFieldPopover: React.FC = ({ [field.type.name, databaseType] ); + // Check if this is a SERIAL-type that is inherently auto-incrementing + const forceAutoIncrement = useMemo( + () => autoIncrementAlwaysOn(field.type.name) && !localField.nullable, + [field.type.name, localField.nullable] + ); + + // Auto-increment is disabled if the field is nullable (auto-increment requires NOT NULL) + const isIncrementDisabled = useMemo( + () => localField.nullable || readonly || forceAutoIncrement, + [localField.nullable, readonly, forceAutoIncrement] + ); + return ( = ({ )} setLocalField((current) => ({ ...current, diff --git a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field.tsx b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field.tsx index 585a619a7..b3d72d9d7 100644 --- a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field.tsx +++ b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field.tsx @@ -15,6 +15,7 @@ import { CSS } from '@dnd-kit/utilities'; import { SelectBox } from '@/components/select-box/select-box'; import { TableFieldPopover } from './table-field-modal/table-field-modal'; import type { DatabaseType, DBTable } from '@/lib/domain'; +import { requiresNotNull } from '@/lib/data/data-types/data-types'; export interface TableFieldProps { table: DBTable; @@ -55,6 +56,8 @@ export const TableField: React.FC = ({ transition, }; + const typeRequiresNotNull = requiresNotNull(field.type.name); + return (
= ({ N From 9ed27cf30cca1312713e80e525138f0c27154936 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Tue, 21 Oct 2025 15:10:02 +0300 Subject: [PATCH 038/156] fix: preserve multi-word types in DBML export/import (#956) * fix: preserve multi-word types in DBML export/import * fix --- src/lib/data/sql-export/export-sql-script.ts | 8 +- .../dbml/dbml-export/__tests__/cases/3.dbml | 2 +- .../__tests__/empty-tables.test.ts | 205 +++++++++++++++ .../__tests__/timestamp-with-timezone.test.ts | 248 ++++++++++++++++++ src/lib/dbml/dbml-export/dbml-export.ts | 26 +- 5 files changed, 477 insertions(+), 12 deletions(-) create mode 100644 src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts create mode 100644 src/lib/dbml/dbml-export/__tests__/timestamp-with-timezone.test.ts diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index cd019dbd7..e3c102ffe 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -338,7 +338,13 @@ export const exportBaseSQL = ({ const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow); - sqlScript += ` ${quotedFieldName} ${typeName}`; + // Quote multi-word type names for DBML flow to prevent @dbml/core parser issues + const quotedTypeName = + isDBMLFlow && typeName.includes(' ') + ? `"${typeName}"` + : typeName; + + sqlScript += ` ${quotedFieldName} ${quotedTypeName}`; // Add size for character types if ( diff --git a/src/lib/dbml/dbml-export/__tests__/cases/3.dbml b/src/lib/dbml/dbml-export/__tests__/cases/3.dbml index d5fe9e9e6..d815d7d39 100644 --- a/src/lib/dbml/dbml-export/__tests__/cases/3.dbml +++ b/src/lib/dbml/dbml-export/__tests__/cases/3.dbml @@ -1,6 +1,6 @@ Table "public"."guy_table" { "id" integer [pk, not null] - "created_at" timestamp [not null] + "created_at" "timestamp without time zone" [not null] "column3" text "arrayfield" text[] "field_5" "character varying" diff --git a/src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts b/src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts new file mode 100644 index 000000000..79b704c19 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import { generateDBMLFromDiagram } from '../dbml-export'; +import { DatabaseType } from '@/lib/domain/database-type'; +import type { Diagram } from '@/lib/domain/diagram'; +import { generateId, generateDiagramId } from '@/lib/utils'; + +describe('DBML Export - Empty Tables', () => { + it('should filter out tables with no fields', () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'valid_table', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'empty_table', + schema: 'public', + x: 0, + y: 0, + fields: [], // Empty fields array + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'another_valid_table', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'name', + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = generateDBMLFromDiagram(diagram); + + // Verify the DBML doesn't contain the empty table + expect(result.inlineDbml).not.toContain('empty_table'); + expect(result.standardDbml).not.toContain('empty_table'); + + // Verify the valid tables are still present + expect(result.inlineDbml).toContain('valid_table'); + expect(result.inlineDbml).toContain('another_valid_table'); + }); + + it('should handle diagram with only empty tables', () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'empty_table_1', + schema: 'public', + x: 0, + y: 0, + fields: [], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'empty_table_2', + schema: 'public', + x: 0, + y: 0, + fields: [], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = generateDBMLFromDiagram(diagram); + + // Should not error and should return empty DBML (or just enums if any) + expect(result.inlineDbml).toBeTruthy(); + expect(result.standardDbml).toBeTruthy(); + expect(result.error).toBeUndefined(); + }); + + it('should filter out table that becomes empty after removing invalid fields', () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'table_with_only_empty_field_names', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: '', // Empty field name - will be filtered + type: { id: 'integer', name: 'integer' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + { + id: generateId(), + name: '', // Empty field name - will be filtered + type: { id: 'varchar', name: 'varchar' }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'valid_table', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = generateDBMLFromDiagram(diagram); + + // Table with only empty field names should be filtered out + expect(result.inlineDbml).not.toContain( + 'table_with_only_empty_field_names' + ); + // Valid table should remain + expect(result.inlineDbml).toContain('valid_table'); + }); +}); diff --git a/src/lib/dbml/dbml-export/__tests__/timestamp-with-timezone.test.ts b/src/lib/dbml/dbml-export/__tests__/timestamp-with-timezone.test.ts new file mode 100644 index 000000000..35f5dd3a1 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/timestamp-with-timezone.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest'; +import { generateDBMLFromDiagram } from '../dbml-export'; +import { importDBMLToDiagram } from '../../dbml-import/dbml-import'; +import { DatabaseType } from '@/lib/domain/database-type'; +import type { Diagram } from '@/lib/domain/diagram'; +import { generateId, generateDiagramId } from '@/lib/utils'; + +describe('DBML Export - Timestamp with Time Zone', () => { + it('should preserve "timestamp with time zone" type through export and reimport', async () => { + // Create a diagram with timestamp with time zone field + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'events', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'created_at', + type: { + id: 'timestamp_with_time_zone', + name: 'timestamp with time zone', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'updated_at', + type: { + id: 'timestamp_without_time_zone', + name: 'timestamp without time zone', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + // Export to DBML + const exportResult = generateDBMLFromDiagram(diagram); + + // Verify the DBML contains quoted multi-word types + expect(exportResult.inlineDbml).toContain('"timestamp with time zone"'); + expect(exportResult.inlineDbml).toContain( + '"timestamp without time zone"' + ); + + // Reimport the DBML + const reimportedDiagram = await importDBMLToDiagram( + exportResult.inlineDbml, + { + databaseType: DatabaseType.POSTGRESQL, + } + ); + + // Verify the types are preserved + const table = reimportedDiagram.tables?.find( + (t) => t.name === 'events' + ); + expect(table).toBeDefined(); + + const createdAtField = table?.fields.find( + (f) => f.name === 'created_at' + ); + const updatedAtField = table?.fields.find( + (f) => f.name === 'updated_at' + ); + + expect(createdAtField?.type.name).toBe('timestamp with time zone'); + expect(updatedAtField?.type.name).toBe('timestamp without time zone'); + }); + + it('should handle time with time zone types', async () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'schedules', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'start_time', + type: { + id: 'time_with_time_zone', + name: 'time with time zone', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'end_time', + type: { + id: 'time_without_time_zone', + name: 'time without time zone', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const exportResult = generateDBMLFromDiagram(diagram); + + expect(exportResult.inlineDbml).toContain('"time with time zone"'); + expect(exportResult.inlineDbml).toContain('"time without time zone"'); + + const reimportedDiagram = await importDBMLToDiagram( + exportResult.inlineDbml, + { + databaseType: DatabaseType.POSTGRESQL, + } + ); + + const table = reimportedDiagram.tables?.find( + (t) => t.name === 'schedules' + ); + const startTimeField = table?.fields.find( + (f) => f.name === 'start_time' + ); + const endTimeField = table?.fields.find((f) => f.name === 'end_time'); + + expect(startTimeField?.type.name).toBe('time with time zone'); + expect(endTimeField?.type.name).toBe('time without time zone'); + }); + + it('should handle double precision type', async () => { + const diagram: Diagram = { + id: generateDiagramId(), + name: 'Test Diagram', + databaseType: DatabaseType.POSTGRESQL, + tables: [ + { + id: generateId(), + name: 'measurements', + schema: 'public', + x: 0, + y: 0, + fields: [ + { + id: generateId(), + name: 'id', + type: { id: 'integer', name: 'integer' }, + primaryKey: true, + unique: true, + nullable: false, + createdAt: Date.now(), + }, + { + id: generateId(), + name: 'value', + type: { + id: 'double_precision', + name: 'double precision', + }, + primaryKey: false, + unique: false, + nullable: true, + createdAt: Date.now(), + }, + ], + indexes: [], + color: '#8eb7ff', + isView: false, + createdAt: Date.now(), + }, + ], + relationships: [], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const exportResult = generateDBMLFromDiagram(diagram); + + expect(exportResult.inlineDbml).toContain('"double precision"'); + + const reimportedDiagram = await importDBMLToDiagram( + exportResult.inlineDbml, + { + databaseType: DatabaseType.POSTGRESQL, + } + ); + + const table = reimportedDiagram.tables?.find( + (t) => t.name === 'measurements' + ); + const valueField = table?.fields.find((f) => f.name === 'value'); + + expect(valueField?.type.name).toBe('double precision'); + }); +}); diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index 5819a718d..231f20117 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -807,31 +807,37 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { }; }) ?? []; - // Remove duplicate tables (consider both schema and table name) + // Filter out empty tables and duplicates in a single pass for performance const seenTableIdentifiers = new Set(); - const uniqueTables = sanitizedTables.filter((table) => { + const tablesWithFields = sanitizedTables.filter((table) => { + // Skip tables with no fields (empty tables cause DBML export to fail) + if (table.fields.length === 0) { + return false; + } + // Create a unique identifier combining schema and table name const tableIdentifier = table.schema ? `${table.schema}.${table.name}` : table.name; + // Skip duplicate tables if (seenTableIdentifiers.has(tableIdentifier)) { - return false; // Skip duplicate + return false; } seenTableIdentifiers.add(tableIdentifier); - return true; // Keep unique table + return true; // Keep unique, non-empty table }); // Create the base filtered diagram structure const filteredDiagram: Diagram = { ...diagram, - tables: uniqueTables, + tables: tablesWithFields, relationships: diagram.relationships?.filter((rel) => { - const sourceTable = uniqueTables.find( + const sourceTable = tablesWithFields.find( (t) => t.id === rel.sourceTableId ); - const targetTable = uniqueTables.find( + const targetTable = tablesWithFields.find( (t) => t.id === rel.targetTableId ); const sourceFieldExists = sourceTable?.fields.some( @@ -931,13 +937,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { ); // Restore schema information that may have been stripped by DBML importer - standard = restoreTableSchemas(standard, uniqueTables); + standard = restoreTableSchemas(standard, tablesWithFields); // Restore composite primary key names - standard = restoreCompositePKNames(standard, uniqueTables); + standard = restoreCompositePKNames(standard, tablesWithFields); // Restore increment attribute for auto-incrementing fields - standard = restoreIncrementAttribute(standard, uniqueTables); + standard = restoreIncrementAttribute(standard, tablesWithFields); // Prepend Enum DBML to the standard output if (enumsDBML) { From 9e8979d062d1095ab8e72391c266dbd11b8b9422 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Tue, 21 Oct 2025 15:44:49 +0300 Subject: [PATCH 039/156] update translate (#957) --- src/i18n/locales/ar.ts | 2 +- src/i18n/locales/bn.ts | 2 +- src/i18n/locales/de.ts | 2 +- src/i18n/locales/en.ts | 2 +- src/i18n/locales/es.ts | 2 +- src/i18n/locales/fr.ts | 2 +- src/i18n/locales/gu.ts | 2 +- src/i18n/locales/hi.ts | 2 +- src/i18n/locales/hr.ts | 2 +- src/i18n/locales/id_ID.ts | 2 +- src/i18n/locales/ja.ts | 2 +- src/i18n/locales/ko_KR.ts | 2 +- src/i18n/locales/mr.ts | 2 +- src/i18n/locales/ne.ts | 2 +- src/i18n/locales/pt_BR.ts | 2 +- src/i18n/locales/ru.ts | 2 +- src/i18n/locales/te.ts | 2 +- src/i18n/locales/tr.ts | 2 +- src/i18n/locales/uk.ts | 2 +- src/i18n/locales/vi.ts | 2 +- src/i18n/locales/zh_CN.ts | 2 +- src/i18n/locales/zh_TW.ts | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index ffe995564..e7c1f8dc6 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -308,7 +308,7 @@ export const ar: LanguageTranslation = { cancel: 'إلغاء', import_from_file: 'استيراد من ملف', back: 'رجوع', - empty_diagram: 'مخطط فارغ', + empty_diagram: 'قاعدة بيانات فارغة', continue: 'متابعة', import: 'استيراد', }, diff --git a/src/i18n/locales/bn.ts b/src/i18n/locales/bn.ts index 56de55e3d..75a4166b8 100644 --- a/src/i18n/locales/bn.ts +++ b/src/i18n/locales/bn.ts @@ -310,7 +310,7 @@ export const bn: LanguageTranslation = { cancel: 'বাতিল করুন', back: 'ফিরে যান', import_from_file: 'ফাইল থেকে আমদানি করুন', - empty_diagram: 'ফাঁকা চিত্র', + empty_diagram: 'খালি ডাটাবেস', continue: 'চালিয়ে যান', import: 'আমদানি করুন', }, diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 06d977bf2..097ba181c 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -313,7 +313,7 @@ export const de: LanguageTranslation = { back: 'Zurück', // TODO: Translate import_from_file: 'Import from File', - empty_diagram: 'Leeres Diagramm', + empty_diagram: 'Leere Datenbank', continue: 'Weiter', import: 'Importieren', }, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f8a5a4505..1af7ca099 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -301,7 +301,7 @@ export const en = { cancel: 'Cancel', import_from_file: 'Import from File', back: 'Back', - empty_diagram: 'Empty diagram', + empty_diagram: 'Empty database', continue: 'Continue', import: 'Import', }, diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 13f3f5cbd..399a74e8c 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -310,7 +310,7 @@ export const es: LanguageTranslation = { back: 'Atrás', // TODO: Translate import_from_file: 'Import from File', - empty_diagram: 'Diagrama vacío', + empty_diagram: 'Base de datos vacía', continue: 'Continuar', import: 'Importar', }, diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index df07b6880..c95746d0e 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -307,7 +307,7 @@ export const fr: LanguageTranslation = { cancel: 'Annuler', back: 'Retour', import_from_file: "Importer à partir d'un fichier", - empty_diagram: 'Diagramme vide', + empty_diagram: 'Base de données vide', continue: 'Continuer', import: 'Importer', }, diff --git a/src/i18n/locales/gu.ts b/src/i18n/locales/gu.ts index 028007d59..334f78312 100644 --- a/src/i18n/locales/gu.ts +++ b/src/i18n/locales/gu.ts @@ -310,7 +310,7 @@ export const gu: LanguageTranslation = { cancel: 'રદ કરો', back: 'પાછા', import_from_file: 'ફાઇલમાંથી આયાત કરો', - empty_diagram: 'ખાલી ડાયાગ્રામ', + empty_diagram: 'ખાલી ડેટાબેસ', continue: 'ચાલુ રાખો', import: 'આયાત કરો', }, diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index fdbd56405..35c49059b 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -312,7 +312,7 @@ export const hi: LanguageTranslation = { back: 'वापस', // TODO: Translate import_from_file: 'Import from File', - empty_diagram: 'खाली आरेख', + empty_diagram: 'खाली डेटाबेस', continue: 'जारी रखें', import: 'आयात करें', }, diff --git a/src/i18n/locales/hr.ts b/src/i18n/locales/hr.ts index 1814e8a6e..18a3c5a3d 100644 --- a/src/i18n/locales/hr.ts +++ b/src/i18n/locales/hr.ts @@ -305,7 +305,7 @@ export const hr: LanguageTranslation = { cancel: 'Odustani', import_from_file: 'Uvezi iz datoteke', back: 'Natrag', - empty_diagram: 'Prazan dijagram', + empty_diagram: 'Prazna baza podataka', continue: 'Nastavi', import: 'Uvezi', }, diff --git a/src/i18n/locales/id_ID.ts b/src/i18n/locales/id_ID.ts index 8185903aa..99fddec45 100644 --- a/src/i18n/locales/id_ID.ts +++ b/src/i18n/locales/id_ID.ts @@ -309,7 +309,7 @@ export const id_ID: LanguageTranslation = { cancel: 'Batal', import_from_file: 'Impor dari file', back: 'Kembali', - empty_diagram: 'Diagram Kosong', + empty_diagram: 'Database Kosong', continue: 'Lanjutkan', import: 'Impor', }, diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 47e0f3b62..ec82a8e25 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -314,7 +314,7 @@ export const ja: LanguageTranslation = { back: '戻る', // TODO: Translate import_from_file: 'Import from File', - empty_diagram: '空のダイアグラム', + empty_diagram: '空のデータベース', continue: '続行', import: 'インポート', }, diff --git a/src/i18n/locales/ko_KR.ts b/src/i18n/locales/ko_KR.ts index b660397d6..b3d352700 100644 --- a/src/i18n/locales/ko_KR.ts +++ b/src/i18n/locales/ko_KR.ts @@ -309,7 +309,7 @@ export const ko_KR: LanguageTranslation = { cancel: '취소', back: '뒤로가기', import_from_file: '파일에서 가져오기', - empty_diagram: '빈 다이어그램으로 시작', + empty_diagram: '빈 데이터베이스', continue: '계속', import: '가져오기', }, diff --git a/src/i18n/locales/mr.ts b/src/i18n/locales/mr.ts index 18b0f74b7..5648a2009 100644 --- a/src/i18n/locales/mr.ts +++ b/src/i18n/locales/mr.ts @@ -315,7 +315,7 @@ export const mr: LanguageTranslation = { // TODO: Add translations import_from_file: 'Import from File', back: 'मागे', - empty_diagram: 'रिक्त आरेख', + empty_diagram: 'रिक्त डेटाबेस', continue: 'सुरू ठेवा', import: 'आयात करा', }, diff --git a/src/i18n/locales/ne.ts b/src/i18n/locales/ne.ts index d99922019..a5b858c74 100644 --- a/src/i18n/locales/ne.ts +++ b/src/i18n/locales/ne.ts @@ -311,7 +311,7 @@ export const ne: LanguageTranslation = { cancel: 'रद्द गर्नुहोस्', import_from_file: 'फाइलबाट आयात गर्नुहोस्', back: 'फर्क', - empty_diagram: 'रिक्त डायाग्राम', + empty_diagram: 'खाली डाटाबेस', continue: 'जारी राख्नुहोस्', import: 'आयात गर्नुहोस्', }, diff --git a/src/i18n/locales/pt_BR.ts b/src/i18n/locales/pt_BR.ts index 1f30024fd..b16a9c8ec 100644 --- a/src/i18n/locales/pt_BR.ts +++ b/src/i18n/locales/pt_BR.ts @@ -311,7 +311,7 @@ export const pt_BR: LanguageTranslation = { back: 'Voltar', // TODO: Translate import_from_file: 'Import from File', - empty_diagram: 'Diagrama vazio', + empty_diagram: 'Banco de dados vazio', continue: 'Continuar', import: 'Importar', }, diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 8a3de3dba..933b32b29 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -307,7 +307,7 @@ export const ru: LanguageTranslation = { cancel: 'Отменить', back: 'Назад', import_from_file: 'Импортировать из файла', - empty_diagram: 'Пустая диаграмма', + empty_diagram: 'Пустая база данных', continue: 'Продолжить', import: 'Импорт', }, diff --git a/src/i18n/locales/te.ts b/src/i18n/locales/te.ts index ed258e6ee..e26d3a724 100644 --- a/src/i18n/locales/te.ts +++ b/src/i18n/locales/te.ts @@ -312,7 +312,7 @@ export const te: LanguageTranslation = { // TODO: Translate import_from_file: 'Import from File', back: 'తిరుగు', - empty_diagram: 'ఖాళీ చిత్రము', + empty_diagram: 'ఖాళీ డేటాబేస్', continue: 'కొనసాగించు', import: 'డిగుమతి', }, diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index da0790caa..5029a671e 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -308,7 +308,7 @@ export const tr: LanguageTranslation = { import_from_file: 'Import from File', cancel: 'İptal', back: 'Geri', - empty_diagram: 'Boş diyagram', + empty_diagram: 'Boş veritabanı', continue: 'Devam', import: 'İçe Aktar', }, diff --git a/src/i18n/locales/uk.ts b/src/i18n/locales/uk.ts index d0f673b74..f7fa03ed7 100644 --- a/src/i18n/locales/uk.ts +++ b/src/i18n/locales/uk.ts @@ -308,7 +308,7 @@ export const uk: LanguageTranslation = { cancel: 'Скасувати', back: 'Назад', import_from_file: 'Імпортувати з файлу', - empty_diagram: 'Порожня діаграма', + empty_diagram: 'Порожня база даних', continue: 'Продовжити', import: 'Імпорт', }, diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index a3532d0d3..52695c073 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -309,7 +309,7 @@ export const vi: LanguageTranslation = { cancel: 'Hủy', import_from_file: 'Nhập từ tệp', back: 'Trở lại', - empty_diagram: 'Sơ đồ trống', + empty_diagram: 'Cơ sở dữ liệu trống', continue: 'Tiếp tục', import: 'Nhập', }, diff --git a/src/i18n/locales/zh_CN.ts b/src/i18n/locales/zh_CN.ts index d61ad374f..0cf6b51dc 100644 --- a/src/i18n/locales/zh_CN.ts +++ b/src/i18n/locales/zh_CN.ts @@ -306,7 +306,7 @@ export const zh_CN: LanguageTranslation = { cancel: '取消', import_from_file: '从文件导入', back: '上一步', - empty_diagram: '新建空关系图', + empty_diagram: '空数据库', continue: '下一步', import: '导入', }, diff --git a/src/i18n/locales/zh_TW.ts b/src/i18n/locales/zh_TW.ts index f92060613..56ea91fb8 100644 --- a/src/i18n/locales/zh_TW.ts +++ b/src/i18n/locales/zh_TW.ts @@ -305,7 +305,7 @@ export const zh_TW: LanguageTranslation = { cancel: '取消', import_from_file: '從檔案匯入', back: '返回', - empty_diagram: '空白圖表', + empty_diagram: '空資料庫', continue: '繼續', import: '匯入', }, From acf6d4b3654d8868b8a8ebf717c608d9749b71da Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Mon, 27 Oct 2025 11:24:23 +0200 Subject: [PATCH 040/156] fix: show SQL Script option conditionally for databases without DDL support (#960) --- .../instructions-section.tsx | 82 ++++++++++--------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/src/dialogs/common/import-database/instructions-section/instructions-section.tsx b/src/dialogs/common/import-database/instructions-section/instructions-section.tsx index 219d76600..90c44fc80 100644 --- a/src/dialogs/common/import-database/instructions-section/instructions-section.tsx +++ b/src/dialogs/common/import-database/instructions-section/instructions-section.tsx @@ -117,35 +117,37 @@ export const InstructionsSection: React.FC = ({
) : null} - {DatabasesWithoutDDLInstructions.includes(databaseType) ? null : ( -
-

- How would you like to import? -

- { - let selectedImportMethod: ImportMethod = 'query'; - if (value) { - selectedImportMethod = value as ImportMethod; - } +
+

+ How would you like to import? +

+ { + let selectedImportMethod: ImportMethod = 'query'; + if (value) { + selectedImportMethod = value as ImportMethod; + } - setImportMethod(selectedImportMethod); - }} + setImportMethod(selectedImportMethod); + }} + > + - - - - Query - - Smart Query - + + + Query + + Smart Query + + {!DatabasesWithoutDDLInstructions.includes( + databaseType + ) && ( = ({ SQL Script - - - - - DBML - - -
- )} + )} + + + + + DBML + +
+
Instructions:
From 91e713c30a44f1ba7a767ca7816079610136fcb8 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Mon, 27 Oct 2025 11:55:11 +0200 Subject: [PATCH 041/156] fix: import array fields (#961) --- src/lib/data/import-metadata/import/fields.ts | 1 + src/lib/data/import-metadata/import/index.ts | 2 +- src/lib/data/import-metadata/metadata-types/column-info.ts | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/data/import-metadata/import/fields.ts b/src/lib/data/import-metadata/import/fields.ts index 53d9830de..10cfd77eb 100644 --- a/src/lib/data/import-metadata/import/fields.ts +++ b/src/lib/data/import-metadata/import/fields.ts @@ -60,6 +60,7 @@ export const createFieldsFromMetadata = ({ ...(col.is_identity !== undefined ? { increment: col.is_identity } : {}), + ...(col.is_array !== undefined ? { isArray: col.is_array } : {}), createdAt: Date.now(), comments: col.comment ? col.comment : undefined, }) diff --git a/src/lib/data/import-metadata/import/index.ts b/src/lib/data/import-metadata/import/index.ts index ecc907fbc..10e0d9efb 100644 --- a/src/lib/data/import-metadata/import/index.ts +++ b/src/lib/data/import-metadata/import/index.ts @@ -64,7 +64,7 @@ export const loadFromDatabaseMetadata = async ({ const diagram: Diagram = { id: generateDiagramId(), name: databaseMetadata.database_name - ? `${databaseMetadata.database_name}-db` + ? `${databaseMetadata.database_name}` : diagramNumber ? `Diagram ${diagramNumber}` : 'New Diagram', diff --git a/src/lib/data/import-metadata/metadata-types/column-info.ts b/src/lib/data/import-metadata/metadata-types/column-info.ts index 45eefb8b4..b434709bc 100644 --- a/src/lib/data/import-metadata/metadata-types/column-info.ts +++ b/src/lib/data/import-metadata/metadata-types/column-info.ts @@ -15,7 +15,8 @@ export interface ColumnInfo { default?: string | null; // Default value for the column, nullable collation?: string | null; comment?: string | null; - is_identity?: boolean; // Indicates if the column is auto-increment/identity + is_identity?: boolean | null; // Indicates if the column is auto-increment/identity + is_array?: boolean | null; // Indicates if the column is an array type } export const ColumnInfoSchema: z.ZodType = z.object({ @@ -36,5 +37,6 @@ export const ColumnInfoSchema: z.ZodType = z.object({ default: z.string().nullable().optional(), collation: z.string().nullable().optional(), comment: z.string().nullable().optional(), - is_identity: z.boolean().optional(), + is_identity: z.boolean().nullable().optional(), + is_array: z.boolean().nullable().optional(), }); From 084a1d505cb8f981537623b7ee5801d787d99dfa Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Mon, 27 Oct 2025 16:27:46 +0200 Subject: [PATCH 042/156] chore(main): release 1.17.0 (#936) --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4c9e37e..0942f7a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [1.17.0](https://github.com/chartdb/chartdb/compare/v1.16.0...v1.17.0) (2025-10-27) + + +### Features + +* create relationships on canvas modal ([#946](https://github.com/chartdb/chartdb/issues/946)) ([34475ad](https://github.com/chartdb/chartdb/commit/34475add32f11323589ef092ccf2a8e9152ff272)) + + +### Bug Fixes + +* add auto-increment field detection in smart-query import ([#935](https://github.com/chartdb/chartdb/issues/935)) ([57b3b87](https://github.com/chartdb/chartdb/commit/57b3b8777fd0a445abf0ba6603faab612d469d5c)) +* add open table in editor from canvas edit ([#952](https://github.com/chartdb/chartdb/issues/952)) ([7d811de](https://github.com/chartdb/chartdb/commit/7d811de097eb11e51012772fa6bf586fd0b16c62)) +* add rels export dbml ([#937](https://github.com/chartdb/chartdb/issues/937)) ([c3c646b](https://github.com/chartdb/chartdb/commit/c3c646bf7cbb1328f4b2eb85c9a7e929f0fcd3b9)) +* add support for arrays ([#949](https://github.com/chartdb/chartdb/issues/949)) ([49328d8](https://github.com/chartdb/chartdb/commit/49328d8fbd7786f6c0c04cd5605d43a24cbf10ea)) +* add support for parsing default values in DBML ([#948](https://github.com/chartdb/chartdb/issues/948)) ([459698b](https://github.com/chartdb/chartdb/commit/459698b5d0a1ff23a3719c2e55e4ab2e2384c4fe)) +* add timestampz and int as datatypes to postgres ([#940](https://github.com/chartdb/chartdb/issues/940)) ([b15bc94](https://github.com/chartdb/chartdb/commit/b15bc945acb96d7cb3832b3b1b607dfcaef9e5ca)) +* auto-enter edit mode when creating new tables from canvas ([#943](https://github.com/chartdb/chartdb/issues/943)) ([bcd8aa9](https://github.com/chartdb/chartdb/commit/bcd8aa9378aa563f40a2b6802cc503be4c882356)) +* dbml diff fields types preview ([#934](https://github.com/chartdb/chartdb/issues/934)) ([bb03309](https://github.com/chartdb/chartdb/commit/bb033091b1f64b888822be1423a80f16f5314f6b)) +* exit table edit on area click ([#945](https://github.com/chartdb/chartdb/issues/945)) ([38fedce](https://github.com/chartdb/chartdb/commit/38fedcec0c10ea2b3f0b7fc92ca1f5ac9e540389)) +* import array fields ([#961](https://github.com/chartdb/chartdb/issues/961)) ([91e713c](https://github.com/chartdb/chartdb/commit/91e713c30a44f1ba7a767ca7816079610136fcb8)) +* manipulate schema directly from the canvas ([#947](https://github.com/chartdb/chartdb/issues/947)) ([7ad0e77](https://github.com/chartdb/chartdb/commit/7ad0e7712de975a23b2a337dc0a4a7fb4b122bd1)) +* preserve multi-word types in DBML export/import ([#956](https://github.com/chartdb/chartdb/issues/956)) ([9ed27cf](https://github.com/chartdb/chartdb/commit/9ed27cf30cca1312713e80e525138f0c27154936)) +* prevent text input glitch when editing table field names ([#944](https://github.com/chartdb/chartdb/issues/944)) ([498655e](https://github.com/chartdb/chartdb/commit/498655e7b77e57eaf641ba86263ce1ef60b93e16)) +* resolve canvas filter tree state issues ([#953](https://github.com/chartdb/chartdb/issues/953)) ([ccb29e0](https://github.com/chartdb/chartdb/commit/ccb29e0a574dfa4cfdf0ebf242a4c4aaa48cc37b)) +* resolve dbml increment & nullable attributes issue ([#954](https://github.com/chartdb/chartdb/issues/954)) ([2c4b344](https://github.com/chartdb/chartdb/commit/2c4b344efb24041e7f607fc6124e109b69aaa457)) +* show SQL Script option conditionally for databases without DDL support ([#960](https://github.com/chartdb/chartdb/issues/960)) ([acf6d4b](https://github.com/chartdb/chartdb/commit/acf6d4b3654d8868b8a8ebf717c608d9749b71da)) +* use flag for custom types ([#951](https://github.com/chartdb/chartdb/issues/951)) ([62dec48](https://github.com/chartdb/chartdb/commit/62dec4857211b705a8039691da1772263ea986fe)) + ## [1.16.0](https://github.com/chartdb/chartdb/compare/v1.15.1...v1.16.0) (2025-09-24) diff --git a/package-lock.json b/package-lock.json index 86fa1d9e4..f3018c1c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chartdb", - "version": "1.16.0", + "version": "1.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chartdb", - "version": "1.16.0", + "version": "1.17.0", "dependencies": { "@ai-sdk/openai": "^0.0.51", "@dbml/core": "^3.13.9", diff --git a/package.json b/package.json index a37841767..1edb28fc8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chartdb", "private": true, - "version": "1.16.0", + "version": "1.17.0", "type": "module", "scripts": { "dev": "vite", From 68412f90a7d4466946b5f20b1b31ae64708d2031 Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Mon, 27 Oct 2025 17:02:44 +0200 Subject: [PATCH 043/156] fix: add postgres array type support for import and export (#958) * fix: add postgres array type support for import and export * fix --------- Co-authored-by: Guy Ben-Aharon --- .../import-metadata/scripts/postgres-script.ts | 16 ++++++++++++++-- .../sql-export/export-per-type/postgresql.ts | 12 ++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/lib/data/import-metadata/scripts/postgres-script.ts b/src/lib/data/import-metadata/scripts/postgres-script.ts index 25df18648..0fbe76a0a 100644 --- a/src/lib/data/import-metadata/scripts/postgres-script.ts +++ b/src/lib/data/import-metadata/scripts/postgres-script.ts @@ -181,7 +181,13 @@ cols AS ( '","table":"', cols.table_name, '","name":"', cols.column_name, '","ordinal_position":', cols.ordinal_position, - ',"type":"', case when LOWER(replace(cols.data_type, '"', '')) = 'user-defined' then pg_type.typname else LOWER(replace(cols.data_type, '"', '')) end, + ',"type":"', CASE WHEN cols.data_type = 'ARRAY' THEN + format_type(pg_type.typelem, NULL) + WHEN LOWER(replace(cols.data_type, '"', '')) = 'user-defined' THEN + format_type(pg_type.oid, NULL) + ELSE + LOWER(replace(cols.data_type, '"', '')) + END, '","character_maximum_length":"', COALESCE(cols.character_maximum_length::text, 'null'), '","precision":', CASE @@ -194,11 +200,15 @@ cols AS ( ',"default":"', ${withExtras ? withDefault : withoutDefault}, '","collation":"', COALESCE(cols.COLLATION_NAME, ''), '","comment":"', ${withExtras ? withComments : withoutComments}, - '","is_identity":', CASE + '","is_identity":', CASE WHEN cols.is_identity = 'YES' THEN 'true' WHEN cols.column_default IS NOT NULL AND cols.column_default LIKE 'nextval(%' THEN 'true' ELSE 'false' END, + ',"is_array":', CASE + WHEN cols.data_type = 'ARRAY' OR pg_type.typelem > 0 THEN 'true' + ELSE 'false' + END, '}')), ',') AS cols_metadata FROM information_schema.columns cols LEFT JOIN pg_catalog.pg_class c @@ -211,6 +221,8 @@ cols AS ( ON attr.attrelid = c.oid AND attr.attname = cols.column_name LEFT JOIN pg_catalog.pg_type ON pg_type.oid = attr.atttypid + LEFT JOIN pg_catalog.pg_type AS elem_type + ON elem_type.oid = pg_type.typelem WHERE cols.table_schema NOT IN ('information_schema', 'pg_catalog')${ databaseEdition === DatabaseEdition.POSTGRESQL_TIMESCALE ? timescaleColFilter diff --git a/src/lib/data/sql-export/export-per-type/postgresql.ts b/src/lib/data/sql-export/export-per-type/postgresql.ts index dbe1ffc5e..daaaedb16 100644 --- a/src/lib/data/sql-export/export-per-type/postgresql.ts +++ b/src/lib/data/sql-export/export-per-type/postgresql.ts @@ -286,10 +286,14 @@ export function exportPostgreSQL({ } } - // Handle array types (check if the type name ends with '[]') - if (typeName.endsWith('[]')) { - typeWithSize = - typeWithSize.replace('[]', '') + '[]'; + // Handle array types (check if isArray flag or if type name ends with '[]') + if (field.isArray || typeName.endsWith('[]')) { + // Remove any existing [] notation + const baseTypeWithoutArray = typeWithSize.replace( + /\[\]$/, + '' + ); + typeWithSize = baseTypeWithoutArray + '[]'; } const notNull = field.nullable ? '' : ' NOT NULL'; From 6d38ebe3ecd271b80c33db7e2731594a39b004d5 Mon Sep 17 00:00:00 2001 From: Guy Ben-Aharon Date: Sun, 2 Nov 2025 15:42:18 +0200 Subject: [PATCH 044/156] feat: add sticky notes (#967) * feat: add sticky notes * feat: add sticky notes --- src/components/empty-state/empty-state.tsx | 52 +- src/components/empty/empty.tsx | 105 ++++ src/context/canvas-context/canvas-context.tsx | 20 + .../canvas-context/canvas-provider.tsx | 6 +- .../chartdb-context/chartdb-context.tsx | 37 ++ .../chartdb-context/chartdb-provider.tsx | 143 ++++++ .../history-context/history-provider.tsx | 27 ++ .../history-context/redo-undo-action.ts | 24 +- src/context/layout-context/layout-context.tsx | 20 +- .../layout-context/layout-provider.tsx | 31 +- .../storage-context/storage-context.tsx | 25 + .../storage-context/storage-provider.tsx | 114 ++++- src/hooks/use-focus-on.ts | 39 ++ src/i18n/locales/ar.ts | 30 +- src/i18n/locales/bn.ts | 32 +- src/i18n/locales/de.ts | 32 +- src/i18n/locales/en.ts | 31 +- src/i18n/locales/es.ts | 32 +- src/i18n/locales/fr.ts | 32 +- src/i18n/locales/gu.ts | 32 +- src/i18n/locales/hi.ts | 32 +- src/i18n/locales/hr.ts | 31 +- src/i18n/locales/id_ID.ts | 32 +- src/i18n/locales/ja.ts | 32 +- src/i18n/locales/ko_KR.ts | 32 +- src/i18n/locales/mr.ts | 32 +- src/i18n/locales/ne.ts | 32 +- src/i18n/locales/pt_BR.ts | 32 +- src/i18n/locales/ru.ts | 32 +- src/i18n/locales/te.ts | 32 +- src/i18n/locales/tr.ts | 32 +- src/i18n/locales/uk.ts | 32 +- src/i18n/locales/vi.ts | 32 +- src/i18n/locales/zh_CN.ts | 31 +- src/i18n/locales/zh_TW.ts | 31 +- src/lib/clone.ts | 21 + src/lib/domain/diagram.ts | 4 + .../diff-check/__tests__/diff-check.test.ts | 459 +++++++++++++++++- src/lib/domain/diff/diff-check/diff-check.ts | 246 +++++++++- src/lib/domain/diff/diff.ts | 41 +- src/lib/domain/diff/note-diff.ts | 77 +++ src/lib/domain/index.ts | 1 + src/lib/domain/note.ts | 23 + .../canvas/area-node/area-node.tsx | 13 +- .../canvas/canvas-context-menu.tsx | 29 +- src/pages/editor-page/canvas/canvas.tsx | 120 ++++- .../canvas/note-node/note-node.tsx | 219 +++++++++ .../editor-sidebar/editor-sidebar.tsx | 28 +- .../editor-page/side-panel/side-panel.tsx | 9 +- .../area-list-item/area-list-item.tsx | 0 .../areas-tab}/areas-list/areas-list.tsx | 0 .../areas-tab/areas-tab.tsx} | 15 +- .../note-list-item/note-list-item.tsx | 157 ++++++ .../notes-tab/notes-list/notes-list.tsx | 123 +++++ .../visuals-section/notes-tab/notes-tab.tsx | 118 +++++ .../visuals-section/visuals-section.tsx | 68 +++ 56 files changed, 3007 insertions(+), 105 deletions(-) create mode 100644 src/components/empty/empty.tsx create mode 100644 src/lib/domain/diff/note-diff.ts create mode 100644 src/lib/domain/note.ts create mode 100644 src/pages/editor-page/canvas/note-node/note-node.tsx rename src/pages/editor-page/side-panel/{areas-section => visuals-section/areas-tab}/areas-list/area-list-item/area-list-item.tsx (100%) rename src/pages/editor-page/side-panel/{areas-section => visuals-section/areas-tab}/areas-list/areas-list.tsx (100%) rename src/pages/editor-page/side-panel/{areas-section/areas-section.tsx => visuals-section/areas-tab/areas-tab.tsx} (94%) create mode 100644 src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-list/note-list-item/note-list-item.tsx create mode 100644 src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-list/notes-list.tsx create mode 100644 src/pages/editor-page/side-panel/visuals-section/notes-tab/notes-tab.tsx create mode 100644 src/pages/editor-page/side-panel/visuals-section/visuals-section.tsx diff --git a/src/components/empty-state/empty-state.tsx b/src/components/empty-state/empty-state.tsx index 1f44db035..3d7186cd2 100644 --- a/src/components/empty-state/empty-state.tsx +++ b/src/components/empty-state/empty-state.tsx @@ -1,9 +1,16 @@ import React, { forwardRef } from 'react'; import EmptyStateImage from '@/assets/empty_state.png'; import EmptyStateImageDark from '@/assets/empty_state_dark.png'; -import { Label } from '@/components/label/label'; import { cn } from '@/lib/utils'; import { useTheme } from '@/hooks/use-theme'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '../empty/empty'; export interface EmptyStateProps { title: string; @@ -38,26 +45,29 @@ export const EmptyState = forwardRef< className )} > - Empty state - - + + + + {/* */} + Empty state + + + {title} + + + {description} + + + +
); } diff --git a/src/components/empty/empty.tsx b/src/components/empty/empty.tsx new file mode 100644 index 000000000..6a0583228 --- /dev/null +++ b/src/components/empty/empty.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils/index'; + +function Empty({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-transparent', + icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function EmptyMedia({ + className, + variant = 'default', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', + className + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +}; diff --git a/src/context/canvas-context/canvas-context.tsx b/src/context/canvas-context/canvas-context.tsx index f9b76ed02..e23bbedfd 100644 --- a/src/context/canvas-context/canvas-context.tsx +++ b/src/context/canvas-context/canvas-context.tsx @@ -2,6 +2,24 @@ import { createContext } from 'react'; import { emptyFn } from '@/lib/utils'; import type { Graph } from '@/lib/graph'; import { createGraph } from '@/lib/graph'; +import { EventEmitter } from 'ahooks/lib/useEventEmitter'; + +export type CanvasEventType = 'pan_click'; + +export type CanvasEventBase = { + action: T; + data: D; +}; + +export type PanClickEvent = CanvasEventBase< + 'pan_click', + { + x: number; + y: number; + } +>; + +export type CanvasEvent = PanClickEvent; export interface CanvasContext { reorderTables: (options?: { updateHistory?: boolean }) => void; @@ -49,6 +67,7 @@ export interface CanvasContext { y: number; }) => void; hideCreateRelationshipNode: () => void; + events: EventEmitter; } export const canvasContext = createContext({ @@ -68,4 +87,5 @@ export const canvasContext = createContext({ setHoveringTableId: emptyFn, showCreateRelationshipNode: emptyFn, hideCreateRelationshipNode: emptyFn, + events: new EventEmitter(), }); diff --git a/src/context/canvas-context/canvas-provider.tsx b/src/context/canvas-context/canvas-provider.tsx index c0ed8c03a..857def067 100644 --- a/src/context/canvas-context/canvas-provider.tsx +++ b/src/context/canvas-context/canvas-provider.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useRef, } from 'react'; -import type { CanvasContext } from './canvas-context'; +import type { CanvasContext, CanvasEvent } from './canvas-context'; import { canvasContext } from './canvas-context'; import { useChartDB } from '@/hooks/use-chartdb'; import { adjustTablePositions } from '@/lib/domain/db-table'; @@ -20,6 +20,7 @@ import { CREATE_RELATIONSHIP_NODE_ID, type CreateRelationshipNodeType, } from '@/pages/editor-page/canvas/create-relationship-node/create-relationship-node'; +import { useEventEmitter } from 'ahooks'; interface CanvasProviderProps { children: ReactNode; @@ -43,6 +44,8 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => { fieldId?: string; } | null>(null); + const events = useEventEmitter(); + const [showFilter, setShowFilter] = useState(false); const [tempFloatingEdge, setTempFloatingEdge] = @@ -212,6 +215,7 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => { setHoveringTableId, showCreateRelationshipNode, hideCreateRelationshipNode, + events, }} > {children} diff --git a/src/context/chartdb-context/chartdb-context.tsx b/src/context/chartdb-context/chartdb-context.tsx index 31cc68b1e..c8450528d 100644 --- a/src/context/chartdb-context/chartdb-context.tsx +++ b/src/context/chartdb-context/chartdb-context.tsx @@ -12,6 +12,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency'; import { EventEmitter } from 'ahooks/lib/useEventEmitter'; import type { Area } from '@/lib/domain/area'; import type { DBCustomType } from '@/lib/domain/db-custom-type'; +import type { Note } from '@/lib/domain/note'; export type ChartDBEventType = | 'add_tables' @@ -74,6 +75,7 @@ export interface ChartDBContext { dependencies: DBDependency[]; areas: Area[]; customTypes: DBCustomType[]; + notes: Note[]; currentDiagram: Diagram; events: EventEmitter; readonly?: boolean; @@ -255,6 +257,31 @@ export interface ChartDBContext { options?: { updateHistory: boolean } ) => Promise; + // Note operations + createNote: (attributes?: Partial>) => Promise; + addNote: ( + note: Note, + options?: { updateHistory: boolean } + ) => Promise; + addNotes: ( + notes: Note[], + options?: { updateHistory: boolean } + ) => Promise; + getNote: (id: string) => Note | null; + removeNote: ( + id: string, + options?: { updateHistory: boolean } + ) => Promise; + removeNotes: ( + ids: string[], + options?: { updateHistory: boolean } + ) => Promise; + updateNote: ( + id: string, + note: Partial, + options?: { updateHistory: boolean } + ) => Promise; + // Custom type operations createCustomType: ( attributes?: Partial> @@ -292,6 +319,7 @@ export const chartDBContext = createContext({ dependencies: [], areas: [], customTypes: [], + notes: [], schemas: [], highlightCustomTypeId: emptyFn, currentDiagram: { @@ -368,6 +396,15 @@ export const chartDBContext = createContext({ removeAreas: emptyFn, updateArea: emptyFn, + // Note operations + createNote: emptyFn, + addNote: emptyFn, + addNotes: emptyFn, + getNote: emptyFn, + removeNote: emptyFn, + removeNotes: emptyFn, + updateNote: emptyFn, + // Custom type operations createCustomType: emptyFn, addCustomType: emptyFn, diff --git a/src/context/chartdb-context/chartdb-provider.tsx b/src/context/chartdb-context/chartdb-provider.tsx index 8559539ce..c748740e7 100644 --- a/src/context/chartdb-context/chartdb-provider.tsx +++ b/src/context/chartdb-context/chartdb-provider.tsx @@ -24,6 +24,7 @@ import { defaultSchemas } from '@/lib/data/default-schemas'; import { useEventEmitter } from 'ahooks'; import type { DBDependency } from '@/lib/domain/db-dependency'; import type { Area } from '@/lib/domain/area'; +import type { Note } from '@/lib/domain/note'; import { storageInitialValue } from '../storage-context/storage-context'; import { useDiff } from '../diff-context/use-diff'; import type { DiffCalculatedEvent } from '../diff-context/diff-context'; @@ -67,6 +68,7 @@ export const ChartDBProvider: React.FC< const [customTypes, setCustomTypes] = useState( diagram?.customTypes ?? [] ); + const [notes, setNotes] = useState(diagram?.notes ?? []); const { events: diffEvents } = useDiff(); @@ -147,6 +149,7 @@ export const ChartDBProvider: React.FC< dependencies, areas, customTypes, + notes, }), [ diagramId, @@ -158,6 +161,7 @@ export const ChartDBProvider: React.FC< dependencies, areas, customTypes, + notes, diagramCreatedAt, diagramUpdatedAt, ] @@ -171,6 +175,7 @@ export const ChartDBProvider: React.FC< setDependencies([]); setAreas([]); setCustomTypes([]); + setNotes([]); setDiagramUpdatedAt(updatedAt); resetRedoStack(); @@ -183,6 +188,7 @@ export const ChartDBProvider: React.FC< db.deleteDiagramDependencies(diagramId), db.deleteDiagramAreas(diagramId), db.deleteDiagramCustomTypes(diagramId), + db.deleteDiagramNotes(diagramId), ]); }, [db, diagramId, resetRedoStack, resetUndoStack]); @@ -197,6 +203,7 @@ export const ChartDBProvider: React.FC< setDependencies([]); setAreas([]); setCustomTypes([]); + setNotes([]); resetRedoStack(); resetUndoStack(); @@ -207,6 +214,7 @@ export const ChartDBProvider: React.FC< db.deleteDiagramDependencies(diagramId), db.deleteDiagramAreas(diagramId), db.deleteDiagramCustomTypes(diagramId), + db.deleteDiagramNotes(diagramId), ]); }, [db, diagramId, resetRedoStack, resetUndoStack]); @@ -1528,6 +1536,130 @@ export const ChartDBProvider: React.FC< [db, diagramId, setAreas, getArea, addUndoAction, resetRedoStack] ); + // Note operations + const addNotes: ChartDBContext['addNotes'] = useCallback( + async (notes: Note[], options = { updateHistory: true }) => { + setNotes((currentNotes) => [...currentNotes, ...notes]); + + const updatedAt = new Date(); + setDiagramUpdatedAt(updatedAt); + + await Promise.all([ + ...notes.map((note) => db.addNote({ diagramId, note })), + db.updateDiagram({ id: diagramId, attributes: { updatedAt } }), + ]); + + if (options.updateHistory) { + addUndoAction({ + action: 'addNotes', + redoData: { notes }, + undoData: { noteIds: notes.map((n) => n.id) }, + }); + resetRedoStack(); + } + }, + [db, diagramId, setNotes, addUndoAction, resetRedoStack] + ); + + const addNote: ChartDBContext['addNote'] = useCallback( + async (note: Note, options = { updateHistory: true }) => { + return addNotes([note], options); + }, + [addNotes] + ); + + const createNote: ChartDBContext['createNote'] = useCallback( + async (attributes) => { + const note: Note = { + id: generateId(), + content: '', + x: 0, + y: 0, + width: 200, + height: 150, + color: '#ffe374', // Default warm yellow + ...attributes, + }; + + await addNote(note); + + return note; + }, + [addNote] + ); + + const getNote: ChartDBContext['getNote'] = useCallback( + (id: string) => notes.find((note) => note.id === id) ?? null, + [notes] + ); + + const removeNotes: ChartDBContext['removeNotes'] = useCallback( + async (ids: string[], options = { updateHistory: true }) => { + const prevNotes = [ + ...notes.filter((note) => ids.includes(note.id)), + ]; + + setNotes((notes) => notes.filter((note) => !ids.includes(note.id))); + + const updatedAt = new Date(); + setDiagramUpdatedAt(updatedAt); + + await Promise.all([ + ...ids.map((id) => db.deleteNote({ diagramId, id })), + db.updateDiagram({ id: diagramId, attributes: { updatedAt } }), + ]); + + if (prevNotes.length > 0 && options.updateHistory) { + addUndoAction({ + action: 'removeNotes', + redoData: { noteIds: ids }, + undoData: { notes: prevNotes }, + }); + resetRedoStack(); + } + }, + [db, diagramId, setNotes, notes, addUndoAction, resetRedoStack] + ); + + const removeNote: ChartDBContext['removeNote'] = useCallback( + async (id: string, options = { updateHistory: true }) => { + return removeNotes([id], options); + }, + [removeNotes] + ); + + const updateNote: ChartDBContext['updateNote'] = useCallback( + async ( + id: string, + note: Partial, + options = { updateHistory: true } + ) => { + const prevNote = getNote(id); + + setNotes((notes) => + notes.map((n) => (n.id === id ? { ...n, ...note } : n)) + ); + + const updatedAt = new Date(); + setDiagramUpdatedAt(updatedAt); + + await Promise.all([ + db.updateDiagram({ id: diagramId, attributes: { updatedAt } }), + db.updateNote({ id, attributes: note }), + ]); + + if (!!prevNote && options.updateHistory) { + addUndoAction({ + action: 'updateNote', + redoData: { noteId: id, note }, + undoData: { noteId: id, note: prevNote }, + }); + resetRedoStack(); + } + }, + [db, diagramId, setNotes, getNote, addUndoAction, resetRedoStack] + ); + const highlightCustomTypeId = useCallback( (id?: string) => setHighlightedCustomTypeId(id), [setHighlightedCustomTypeId] @@ -1554,6 +1686,7 @@ export const ChartDBProvider: React.FC< setDiagramCreatedAt(diagram.createdAt); setDiagramUpdatedAt(diagram.updatedAt); setHighlightedCustomTypeId(undefined); + setNotes(diagram.notes ?? []); events.emit({ action: 'load_diagram', data: { diagram } }); @@ -1574,6 +1707,7 @@ export const ChartDBProvider: React.FC< setDiagramUpdatedAt, setHighlightedCustomTypeId, events, + setNotes, resetRedoStack, resetUndoStack, ] @@ -1597,6 +1731,7 @@ export const ChartDBProvider: React.FC< includeDependencies: true, includeAreas: true, includeCustomTypes: true, + includeNotes: true, }); if (diagram) { @@ -1762,6 +1897,7 @@ export const ChartDBProvider: React.FC< relationships, dependencies, areas, + notes, currentDiagram, schemas, events, @@ -1825,6 +1961,13 @@ export const ChartDBProvider: React.FC< updateCustomType, highlightCustomTypeId, highlightedCustomType, + createNote, + addNote, + addNotes, + getNote, + removeNote, + removeNotes, + updateNote, }} > {children} diff --git a/src/context/history-context/history-provider.tsx b/src/context/history-context/history-provider.tsx index f15633043..8c814daac 100644 --- a/src/context/history-context/history-provider.tsx +++ b/src/context/history-context/history-provider.tsx @@ -39,6 +39,9 @@ export const HistoryProvider: React.FC = ({ addCustomTypes, removeCustomTypes, updateCustomType, + addNotes, + removeNotes, + updateNote, } = useChartDB(); const redoActionHandlers = useMemo( @@ -135,6 +138,15 @@ export const HistoryProvider: React.FC = ({ updateHistory: false, }); }, + addNotes: ({ redoData: { notes } }) => { + return addNotes(notes, { updateHistory: false }); + }, + removeNotes: ({ redoData: { noteIds } }) => { + return removeNotes(noteIds, { updateHistory: false }); + }, + updateNote: ({ redoData: { noteId, note } }) => { + return updateNote(noteId, note, { updateHistory: false }); + }, }), [ addTables, @@ -160,6 +172,9 @@ export const HistoryProvider: React.FC = ({ addCustomTypes, removeCustomTypes, updateCustomType, + addNotes, + removeNotes, + updateNote, ] ); @@ -271,6 +286,15 @@ export const HistoryProvider: React.FC = ({ updateHistory: false, }); }, + addNotes: ({ undoData: { noteIds } }) => { + return removeNotes(noteIds, { updateHistory: false }); + }, + removeNotes: ({ undoData: { notes } }) => { + return addNotes(notes, { updateHistory: false }); + }, + updateNote: ({ undoData: { noteId, note } }) => { + return updateNote(noteId, note, { updateHistory: false }); + }, }), [ addTables, @@ -296,6 +320,9 @@ export const HistoryProvider: React.FC = ({ addCustomTypes, removeCustomTypes, updateCustomType, + addNotes, + removeNotes, + updateNote, ] ); diff --git a/src/context/history-context/redo-undo-action.ts b/src/context/history-context/redo-undo-action.ts index ec73da8a4..ef32f6b04 100644 --- a/src/context/history-context/redo-undo-action.ts +++ b/src/context/history-context/redo-undo-action.ts @@ -6,6 +6,7 @@ import type { DBRelationship } from '@/lib/domain/db-relationship'; import type { DBDependency } from '@/lib/domain/db-dependency'; import type { Area } from '@/lib/domain/area'; import type { DBCustomType } from '@/lib/domain/db-custom-type'; +import type { Note } from '@/lib/domain/note'; type Action = keyof ChartDBContext; @@ -161,6 +162,24 @@ type RedoUndoActionRemoveCustomTypes = RedoUndoActionBase< { customTypes: DBCustomType[] } >; +type RedoUndoActionAddNotes = RedoUndoActionBase< + 'addNotes', + { notes: Note[] }, + { noteIds: string[] } +>; + +type RedoUndoActionUpdateNote = RedoUndoActionBase< + 'updateNote', + { noteId: string; note: Partial }, + { noteId: string; note: Partial } +>; + +type RedoUndoActionRemoveNotes = RedoUndoActionBase< + 'removeNotes', + { noteIds: string[] }, + { notes: Note[] } +>; + export type RedoUndoAction = | RedoUndoActionAddTables | RedoUndoActionRemoveTables @@ -184,7 +203,10 @@ export type RedoUndoAction = | RedoUndoActionRemoveAreas | RedoUndoActionAddCustomTypes | RedoUndoActionUpdateCustomType - | RedoUndoActionRemoveCustomTypes; + | RedoUndoActionRemoveCustomTypes + | RedoUndoActionAddNotes + | RedoUndoActionUpdateNote + | RedoUndoActionRemoveNotes; export type RedoActionData = Extract< RedoUndoAction, diff --git a/src/context/layout-context/layout-context.tsx b/src/context/layout-context/layout-context.tsx index cfd39e88e..0487fa171 100644 --- a/src/context/layout-context/layout-context.tsx +++ b/src/context/layout-context/layout-context.tsx @@ -5,8 +5,10 @@ export type SidebarSection = | 'dbml' | 'tables' | 'refs' - | 'areas' - | 'customTypes'; + | 'customTypes' + | 'visuals'; + +export type VisualsTab = 'areas' | 'notes'; export interface LayoutContext { openedTableInSidebar: string | undefined; @@ -27,6 +29,10 @@ export interface LayoutContext { openAreaFromSidebar: (areaId: string) => void; closeAllAreasInSidebar: () => void; + openedNoteInSidebar: string | undefined; + openNoteFromSidebar: (noteId: string) => void; + closeAllNotesInSidebar: () => void; + openedCustomTypeInSidebar: string | undefined; openCustomTypeFromSidebar: (customTypeId: string) => void; closeAllCustomTypesInSidebar: () => void; @@ -34,6 +40,9 @@ export interface LayoutContext { selectedSidebarSection: SidebarSection; selectSidebarSection: (section: SidebarSection) => void; + selectedVisualsTab: VisualsTab; + selectVisualsTab: (tab: VisualsTab) => void; + isSidePanelShowed: boolean; hideSidePanel: () => void; showSidePanel: () => void; @@ -58,6 +67,10 @@ export const layoutContext = createContext({ openAreaFromSidebar: emptyFn, closeAllAreasInSidebar: emptyFn, + openedNoteInSidebar: undefined, + openNoteFromSidebar: emptyFn, + closeAllNotesInSidebar: emptyFn, + openedCustomTypeInSidebar: undefined, openCustomTypeFromSidebar: emptyFn, closeAllCustomTypesInSidebar: emptyFn, @@ -66,6 +79,9 @@ export const layoutContext = createContext({ openTableFromSidebar: emptyFn, closeAllTablesInSidebar: emptyFn, + selectedVisualsTab: 'areas', + selectVisualsTab: emptyFn, + isSidePanelShowed: false, hideSidePanel: emptyFn, showSidePanel: emptyFn, diff --git a/src/context/layout-context/layout-provider.tsx b/src/context/layout-context/layout-provider.tsx index 5849387fd..e7241838c 100644 --- a/src/context/layout-context/layout-provider.tsx +++ b/src/context/layout-context/layout-provider.tsx @@ -1,5 +1,9 @@ import React from 'react'; -import type { LayoutContext, SidebarSection } from './layout-context'; +import type { + LayoutContext, + SidebarSection, + VisualsTab, +} from './layout-context'; import { layoutContext } from './layout-context'; import { useBreakpoint } from '@/hooks/use-breakpoint'; @@ -16,10 +20,15 @@ export const LayoutProvider: React.FC = ({ const [openedAreaInSidebar, setOpenedAreaInSidebar] = React.useState< string | undefined >(); + const [openedNoteInSidebar, setOpenedNoteInSidebar] = React.useState< + string | undefined + >(); const [openedCustomTypeInSidebar, setOpenedCustomTypeInSidebar] = React.useState(); const [selectedSidebarSection, setSelectedSidebarSection] = React.useState('tables'); + const [selectedVisualsTab, setSelectedVisualsTab] = + React.useState('areas'); const [isSidePanelShowed, setIsSidePanelShowed] = React.useState(isDesktop); @@ -38,6 +47,9 @@ export const LayoutProvider: React.FC = ({ const closeAllAreasInSidebar: LayoutContext['closeAllAreasInSidebar'] = () => setOpenedAreaInSidebar(''); + const closeAllNotesInSidebar: LayoutContext['closeAllNotesInSidebar'] = + () => setOpenedNoteInSidebar(''); + const closeAllCustomTypesInSidebar: LayoutContext['closeAllCustomTypesInSidebar'] = () => setOpenedCustomTypeInSidebar(''); @@ -83,10 +95,20 @@ export const LayoutProvider: React.FC = ({ areaId ) => { showSidePanel(); - setSelectedSidebarSection('areas'); + setSelectedSidebarSection('visuals'); + setSelectedVisualsTab('areas'); setOpenedAreaInSidebar(areaId); }; + const openNoteFromSidebar: LayoutContext['openNoteFromSidebar'] = ( + noteId + ) => { + showSidePanel(); + setSelectedSidebarSection('visuals'); + setSelectedVisualsTab('notes'); + setOpenedNoteInSidebar(noteId); + }; + const openCustomTypeFromSidebar: LayoutContext['openCustomTypeFromSidebar'] = (customTypeId) => { showSidePanel(); @@ -116,9 +138,14 @@ export const LayoutProvider: React.FC = ({ openedAreaInSidebar, openAreaFromSidebar, closeAllAreasInSidebar, + openedNoteInSidebar, + openNoteFromSidebar, + closeAllNotesInSidebar, openedCustomTypeInSidebar, openCustomTypeFromSidebar, closeAllCustomTypesInSidebar, + selectedVisualsTab, + selectVisualsTab: setSelectedVisualsTab, }} > {children} diff --git a/src/context/storage-context/storage-context.tsx b/src/context/storage-context/storage-context.tsx index 565f4997a..a56ee06bd 100644 --- a/src/context/storage-context/storage-context.tsx +++ b/src/context/storage-context/storage-context.tsx @@ -8,6 +8,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency'; import type { Area } from '@/lib/domain/area'; import type { DBCustomType } from '@/lib/domain/db-custom-type'; import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter'; +import type { Note } from '@/lib/domain/note'; export interface StorageContext { // Config operations @@ -30,6 +31,7 @@ export interface StorageContext { includeDependencies?: boolean; includeAreas?: boolean; includeCustomTypes?: boolean; + includeNotes?: boolean; }) => Promise; getDiagram: ( id: string, @@ -39,6 +41,7 @@ export interface StorageContext { includeDependencies?: boolean; includeAreas?: boolean; includeCustomTypes?: boolean; + includeNotes?: boolean; } ) => Promise; updateDiagram: (params: { @@ -135,6 +138,20 @@ export interface StorageContext { }) => Promise; listCustomTypes: (diagramId: string) => Promise; deleteDiagramCustomTypes: (diagramId: string) => Promise; + + // Note operations + addNote: (params: { diagramId: string; note: Note }) => Promise; + getNote: (params: { + diagramId: string; + id: string; + }) => Promise; + updateNote: (params: { + id: string; + attributes: Partial; + }) => Promise; + deleteNote: (params: { diagramId: string; id: string }) => Promise; + listNotes: (diagramId: string) => Promise; + deleteDiagramNotes: (diagramId: string) => Promise; } export const storageInitialValue: StorageContext = { @@ -187,6 +204,14 @@ export const storageInitialValue: StorageContext = { deleteCustomType: emptyFn, listCustomTypes: emptyFn, deleteDiagramCustomTypes: emptyFn, + + // Note operations + addNote: emptyFn, + getNote: emptyFn, + updateNote: emptyFn, + deleteNote: emptyFn, + listNotes: emptyFn, + deleteDiagramNotes: emptyFn, }; export const storageContext = diff --git a/src/context/storage-context/storage-provider.tsx b/src/context/storage-context/storage-provider.tsx index b5ff26223..37cff29b6 100644 --- a/src/context/storage-context/storage-provider.tsx +++ b/src/context/storage-context/storage-provider.tsx @@ -11,6 +11,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency'; import type { Area } from '@/lib/domain/area'; import type { DBCustomType } from '@/lib/domain/db-custom-type'; import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter'; +import type { Note } from '@/lib/domain/note'; export const StorageProvider: React.FC = ({ children, @@ -41,6 +42,10 @@ export const StorageProvider: React.FC = ({ DBCustomType & { diagramId: string }, 'id' // primary key "id" (for the typings only) >; + notes: EntityTable< + Note & { diagramId: string }, + 'id' // primary key "id" (for the typings only) + >; config: EntityTable< ChartDBConfig & { id: number }, 'id' // primary key "id" (for the typings only) @@ -216,6 +221,23 @@ export const StorageProvider: React.FC = ({ tx.table('config').clear(); }); + dexieDB.version(13).stores({ + diagrams: + '++id, name, databaseType, databaseEdition, createdAt, updatedAt', + db_tables: + '++id, diagramId, name, schema, x, y, fields, indexes, color, createdAt, width, comment, isView, isMaterializedView, order', + db_relationships: + '++id, diagramId, name, sourceSchema, sourceTableId, targetSchema, targetTableId, sourceFieldId, targetFieldId, type, createdAt', + db_dependencies: + '++id, diagramId, schema, tableId, dependentSchema, dependentTableId, createdAt', + areas: '++id, diagramId, name, x, y, width, height, color', + db_custom_types: + '++id, diagramId, schema, type, kind, values, fields', + config: '++id, defaultDiagramId', + diagram_filters: 'diagramId, tableIds, schemasIds', + notes: '++id, diagramId, content, x, y, width, height, color', + }); + dexieDB.on('ready', async () => { const config = await dexieDB.config.get(1); @@ -550,6 +572,56 @@ export const StorageProvider: React.FC = ({ [db] ); + // Note operations + const addNote: StorageContext['addNote'] = useCallback( + async ({ note, diagramId }) => { + await db.notes.add({ + ...note, + diagramId, + }); + }, + [db] + ); + + const getNote: StorageContext['getNote'] = useCallback( + async ({ diagramId, id }) => { + return await db.notes.get({ id, diagramId }); + }, + [db] + ); + + const updateNote: StorageContext['updateNote'] = useCallback( + async ({ id, attributes }) => { + await db.notes.update(id, attributes); + }, + [db] + ); + + const deleteNote: StorageContext['deleteNote'] = useCallback( + async ({ diagramId, id }) => { + await db.notes.where({ id, diagramId }).delete(); + }, + [db] + ); + + const listNotes: StorageContext['listNotes'] = useCallback( + async (diagramId) => { + return await db.notes + .where('diagramId') + .equals(diagramId) + .toArray(); + }, + [db] + ); + + const deleteDiagramNotes: StorageContext['deleteDiagramNotes'] = + useCallback( + async (diagramId) => { + await db.notes.where('diagramId').equals(diagramId).delete(); + }, + [db] + ); + const addDiagram: StorageContext['addDiagram'] = useCallback( async ({ diagram }) => { const promises = []; @@ -597,9 +669,22 @@ export const StorageProvider: React.FC = ({ ) ); + const notes = diagram.notes ?? []; + promises.push( + ...notes.map((note) => addNote({ diagramId: diagram.id, note })) + ); + await Promise.all(promises); }, - [db, addArea, addCustomType, addDependency, addRelationship, addTable] + [ + db, + addArea, + addCustomType, + addDependency, + addRelationship, + addTable, + addNote, + ] ); const listDiagrams: StorageContext['listDiagrams'] = useCallback( @@ -610,6 +695,7 @@ export const StorageProvider: React.FC = ({ includeDependencies: false, includeAreas: false, includeCustomTypes: false, + includeNotes: false, } ): Promise => { let diagrams = await db.diagrams.toArray(); @@ -663,6 +749,15 @@ export const StorageProvider: React.FC = ({ ); } + if (options.includeNotes) { + diagrams = await Promise.all( + diagrams.map(async (diagram) => { + diagram.notes = await listNotes(diagram.id); + return diagram; + }) + ); + } + return diagrams; }, [ @@ -672,6 +767,7 @@ export const StorageProvider: React.FC = ({ listDependencies, listRelationships, listTables, + listNotes, ] ); @@ -684,6 +780,7 @@ export const StorageProvider: React.FC = ({ includeDependencies: false, includeAreas: false, includeCustomTypes: false, + includeNotes: false, } ): Promise => { const diagram = await db.diagrams.get(id); @@ -712,6 +809,10 @@ export const StorageProvider: React.FC = ({ diagram.customTypes = await listCustomTypes(id); } + if (options.includeNotes) { + diagram.notes = await listNotes(id); + } + return diagram; }, [ @@ -721,6 +822,7 @@ export const StorageProvider: React.FC = ({ listDependencies, listRelationships, listTables, + listNotes, ] ); @@ -749,6 +851,9 @@ export const StorageProvider: React.FC = ({ .where('diagramId') .equals(id) .modify({ diagramId: attributes.id }), + db.notes.where('diagramId').equals(id).modify({ + diagramId: attributes.id, + }), ]); } }, @@ -764,6 +869,7 @@ export const StorageProvider: React.FC = ({ db.db_dependencies.where('diagramId').equals(id).delete(), db.areas.where('diagramId').equals(id).delete(), db.db_custom_types.where('diagramId').equals(id).delete(), + db.notes.where('diagramId').equals(id).delete(), ]); }, [db] @@ -810,6 +916,12 @@ export const StorageProvider: React.FC = ({ deleteCustomType, listCustomTypes, deleteDiagramCustomTypes, + addNote, + getNote, + updateNote, + deleteNote, + listNotes, + deleteDiagramNotes, getDiagramFilter, updateDiagramFilter, deleteDiagramFilter, diff --git a/src/hooks/use-focus-on.ts b/src/hooks/use-focus-on.ts index 17ca76c7f..a00f061d2 100644 --- a/src/hooks/use-focus-on.ts +++ b/src/hooks/use-focus-on.ts @@ -88,6 +88,44 @@ export const useFocusOn = () => { [fitView, setNodes, hideSidePanel, isDesktop] ); + const focusOnNote = useCallback( + (noteId: string, options: FocusOptions = {}) => { + const { select = true } = options; + + if (select) { + setNodes((nodes) => + nodes.map((node) => + node.id === noteId + ? { + ...node, + selected: true, + } + : { + ...node, + selected: false, + } + ) + ); + } + + fitView({ + duration: 500, + maxZoom: 1, + minZoom: 1, + nodes: [ + { + id: noteId, + }, + ], + }); + + if (!isDesktop) { + hideSidePanel(); + } + }, + [fitView, setNodes, hideSidePanel, isDesktop] + ); + const focusOnRelationship = useCallback( ( relationshipId: string, @@ -137,6 +175,7 @@ export const useFocusOn = () => { return { focusOnArea, focusOnTable, + focusOnNote, focusOnRelationship, }; }; diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index e7c1f8dc6..328a8dea9 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -7,9 +7,9 @@ export const ar: LanguageTranslation = { browse: 'تصفح', tables: 'الجداول', refs: 'المراجع', - areas: 'المناطق', dependencies: 'التبعيات', custom_types: 'الأنواع المخصصة', + visuals: 'مرئيات', }, menu: { actions: { @@ -232,6 +232,33 @@ export const ar: LanguageTranslation = { }, }, + visuals_section: { + visuals: 'مرئيات', + tabs: { + areas: 'Areas', + notes: 'ملاحظات', + }, + }, + + notes_section: { + filter: 'تصفية', + add_note: 'إضافة ملاحظة', + no_results: 'لم يتم العثور على ملاحظات', + clear: 'مسح التصفية', + empty_state: { + title: 'لا توجد ملاحظات', + description: 'أنشئ ملاحظة لإضافة تعليقات نصية على اللوحة', + }, + note: { + empty_note: 'ملاحظة فارغة', + note_actions: { + title: 'إجراءات الملاحظة', + edit_content: 'تحرير المحتوى', + delete_note: 'حذف الملاحظة', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -479,6 +506,7 @@ export const ar: LanguageTranslation = { new_relationship: 'علاقة جديدة', // TODO: Translate new_area: 'New Area', + new_note: 'ملاحظة جديدة', }, table_node_context_menu: { diff --git a/src/i18n/locales/bn.ts b/src/i18n/locales/bn.ts index 75a4166b8..e082a0cea 100644 --- a/src/i18n/locales/bn.ts +++ b/src/i18n/locales/bn.ts @@ -7,9 +7,9 @@ export const bn: LanguageTranslation = { browse: 'ব্রাউজ', tables: 'টেবিল', refs: 'রেফস', - areas: 'এলাকা', dependencies: 'নির্ভরতা', custom_types: 'কাস্টম টাইপ', + visuals: 'ভিজ্যুয়াল', }, menu: { actions: { @@ -233,6 +233,35 @@ export const bn: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'ভিজ্যুয়াল', + tabs: { + areas: 'Areas', + notes: 'নোট', + }, + }, + + notes_section: { + filter: 'ফিল্টার', + add_note: 'নোট যোগ করুন', + no_results: 'কোনো নোট পাওয়া যায়নি', + clear: 'ফিল্টার সাফ করুন', + empty_state: { + title: 'কোনো নোট নেই', + description: + 'ক্যানভাসে টেক্সট টীকা যোগ করতে একটি নোট তৈরি করুন', + }, + note: { + empty_note: 'খালি নোট', + note_actions: { + title: 'নোট ক্রিয়া', + edit_content: 'বিষয়বস্তু সম্পাদনা', + delete_note: 'নোট মুছুন', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -484,6 +513,7 @@ export const bn: LanguageTranslation = { new_relationship: 'নতুন সম্পর্ক', // TODO: Translate new_area: 'New Area', + new_note: 'নতুন নোট', }, table_node_context_menu: { diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 097ba181c..647fbb2c9 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -7,9 +7,9 @@ export const de: LanguageTranslation = { browse: 'Durchsuchen', tables: 'Tabellen', refs: 'Refs', - areas: 'Bereiche', dependencies: 'Abhängigkeiten', custom_types: 'Benutzerdefinierte Typen', + visuals: 'Darstellungen', }, menu: { actions: { @@ -234,6 +234,35 @@ export const de: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Darstellungen', + tabs: { + areas: 'Areas', + notes: 'Notizen', + }, + }, + + notes_section: { + filter: 'Filter', + add_note: 'Notiz hinzufügen', + no_results: 'Keine Notizen gefunden', + clear: 'Filter löschen', + empty_state: { + title: 'Keine Notizen', + description: + 'Erstellen Sie eine Notiz, um Textanmerkungen auf der Leinwand hinzuzufügen', + }, + note: { + empty_note: 'Leere Notiz', + note_actions: { + title: 'Notiz-Aktionen', + edit_content: 'Inhalt bearbeiten', + delete_note: 'Notiz löschen', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -487,6 +516,7 @@ export const de: LanguageTranslation = { new_relationship: 'Neue Beziehung', // TODO: Translate new_area: 'New Area', + new_note: 'Neue Notiz', }, table_node_context_menu: { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1af7ca099..6e4c87bbc 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -7,9 +7,9 @@ export const en = { browse: 'Browse', tables: 'Tables', refs: 'Refs', - areas: 'Areas', dependencies: 'Dependencies', custom_types: 'Custom Types', + visuals: 'Visuals', }, menu: { actions: { @@ -227,6 +227,34 @@ export const en = { }, }, + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'Notes', + }, + }, + + notes_section: { + filter: 'Filter', + add_note: 'Add Note', + no_results: 'No notes found', + clear: 'Clear Filter', + empty_state: { + title: 'No Notes', + description: + 'Create a note to add text annotations on the canvas', + }, + note: { + empty_note: 'Empty note', + note_actions: { + title: 'Note Actions', + edit_content: 'Edit Content', + delete_note: 'Delete Note', + }, + }, + }, + custom_types_section: { custom_types: 'Custom Types', filter: 'Filter', @@ -473,6 +501,7 @@ export const en = { new_view: 'New View', new_relationship: 'New Relationship', new_area: 'New Area', + new_note: 'New Note', }, table_node_context_menu: { diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 399a74e8c..971f5cd82 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -7,9 +7,9 @@ export const es: LanguageTranslation = { browse: 'Examinar', tables: 'Tablas', refs: 'Refs', - areas: 'Áreas', dependencies: 'Dependencias', custom_types: 'Tipos Personalizados', + visuals: 'Visuales', }, menu: { actions: { @@ -232,6 +232,35 @@ export const es: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuales', + tabs: { + areas: 'Areas', + notes: 'Notas', + }, + }, + + notes_section: { + filter: 'Filtrar', + add_note: 'Agregar Nota', + no_results: 'No se encontraron notas', + clear: 'Limpiar Filtro', + empty_state: { + title: 'Sin Notas', + description: + 'Crea una nota para agregar anotaciones de texto en el lienzo', + }, + note: { + empty_note: 'Nota vacía', + note_actions: { + title: 'Acciones de Nota', + edit_content: 'Editar Contenido', + delete_note: 'Eliminar Nota', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -486,6 +515,7 @@ export const es: LanguageTranslation = { new_relationship: 'Nueva Relación', // TODO: Translate new_area: 'New Area', + new_note: 'Nueva Nota', }, table_node_context_menu: { diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index c95746d0e..293f98609 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -7,9 +7,9 @@ export const fr: LanguageTranslation = { browse: 'Parcourir', tables: 'Tables', refs: 'Refs', - areas: 'Zones', dependencies: 'Dépendances', custom_types: 'Types Personnalisés', + visuals: 'Visuels', }, menu: { actions: { @@ -230,6 +230,35 @@ export const fr: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuels', + tabs: { + areas: 'Areas', + notes: 'Notes', + }, + }, + + notes_section: { + filter: 'Filtrer', + add_note: 'Ajouter une Note', + no_results: 'Aucune note trouvée', + clear: 'Effacer le Filtre', + empty_state: { + title: 'Pas de Notes', + description: + 'Créez une note pour ajouter des annotations de texte sur le canevas', + }, + note: { + empty_note: 'Note vide', + note_actions: { + title: 'Actions de Note', + edit_content: 'Modifier le Contenu', + delete_note: 'Supprimer la Note', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -482,6 +511,7 @@ export const fr: LanguageTranslation = { new_relationship: 'Nouvelle Relation', // TODO: Translate new_area: 'New Area', + new_note: 'Nouvelle Note', }, table_node_context_menu: { diff --git a/src/i18n/locales/gu.ts b/src/i18n/locales/gu.ts index 334f78312..081d77152 100644 --- a/src/i18n/locales/gu.ts +++ b/src/i18n/locales/gu.ts @@ -7,9 +7,9 @@ export const gu: LanguageTranslation = { browse: 'બ્રાઉજ', tables: 'ટેબલો', refs: 'રેફ્સ', - areas: 'ક્ષેત્રો', dependencies: 'નિર્ભરતાઓ', custom_types: 'કસ્ટમ ટાઇપ', + visuals: 'Visuals', }, menu: { actions: { @@ -234,6 +234,35 @@ export const gu: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'નોંધો', + }, + }, + + notes_section: { + filter: 'ફિલ્ટર', + add_note: 'નોંધ ઉમેરો', + no_results: 'કોઈ નોંધો મળી નથી', + clear: 'ફિલ્ટર સાફ કરો', + empty_state: { + title: 'કોઈ નોંધો નથી', + description: + 'કેનવાસ પર ટેક્સ્ટ એનોટેશન ઉમેરવા માટે નોંધ બનાવો', + }, + note: { + empty_note: 'ખાલી નોંધ', + note_actions: { + title: 'નોંધ ક્રિયાઓ', + edit_content: 'સામગ્રી સંપાદિત કરો', + delete_note: 'નોંધ કાઢી નાખો', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -485,6 +514,7 @@ export const gu: LanguageTranslation = { new_relationship: 'નવો સંબંધ', // TODO: Translate new_area: 'New Area', + new_note: 'નવી નોંધ', }, table_node_context_menu: { diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index 35c49059b..457b73a75 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -7,9 +7,9 @@ export const hi: LanguageTranslation = { browse: 'ब्राउज़', tables: 'टेबल', refs: 'रेफ्स', - areas: 'क्षेत्र', dependencies: 'निर्भरताएं', custom_types: 'कस्टम टाइप', + visuals: 'Visuals', }, menu: { actions: { @@ -233,6 +233,35 @@ export const hi: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'नोट्स', + }, + }, + + notes_section: { + filter: 'फ़िल्टर', + add_note: 'नोट जोड़ें', + no_results: 'कोई नोट नहीं मिला', + clear: 'फ़िल्टर साफ़ करें', + empty_state: { + title: 'कोई नोट नहीं', + description: + 'कैनवास पर टेक्स्ट एनोटेशन जोड़ने के लिए एक नोट बनाएं', + }, + note: { + empty_note: 'खाली नोट', + note_actions: { + title: 'नोट क्रियाएं', + edit_content: 'सामग्री संपादित करें', + delete_note: 'नोट हटाएं', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -487,6 +516,7 @@ export const hi: LanguageTranslation = { new_relationship: 'नया संबंध', // TODO: Translate new_area: 'New Area', + new_note: 'नया नोट', }, table_node_context_menu: { diff --git a/src/i18n/locales/hr.ts b/src/i18n/locales/hr.ts index 18a3c5a3d..47d1caa21 100644 --- a/src/i18n/locales/hr.ts +++ b/src/i18n/locales/hr.ts @@ -7,9 +7,9 @@ export const hr: LanguageTranslation = { browse: 'Pregledaj', tables: 'Tablice', refs: 'Refs', - areas: 'Područja', dependencies: 'Ovisnosti', custom_types: 'Prilagođeni Tipovi', + visuals: 'Vizuali', }, menu: { actions: { @@ -229,6 +229,34 @@ export const hr: LanguageTranslation = { }, }, + visuals_section: { + visuals: 'Vizuali', + tabs: { + areas: 'Područja', + notes: 'Bilješke', + }, + }, + + notes_section: { + filter: 'Filtriraj', + add_note: 'Dodaj Bilješku', + no_results: 'Nije pronađena nijedna bilješka', + clear: 'Očisti Filter', + empty_state: { + title: 'Nema Bilješki', + description: + 'Kreirajte bilješku za dodavanje tekstualnih napomena na platnu', + }, + note: { + empty_note: 'Prazna bilješka', + note_actions: { + title: 'Akcije Bilješke', + edit_content: 'Uredi Sadržaj', + delete_note: 'Obriši Bilješku', + }, + }, + }, + custom_types_section: { custom_types: 'Prilagođeni tipovi', filter: 'Filtriraj', @@ -478,6 +506,7 @@ export const hr: LanguageTranslation = { new_view: 'Novi Pogled', new_relationship: 'Nova veza', new_area: 'Novo područje', + new_note: 'Nova Bilješka', }, table_node_context_menu: { diff --git a/src/i18n/locales/id_ID.ts b/src/i18n/locales/id_ID.ts index 99fddec45..d022d6971 100644 --- a/src/i18n/locales/id_ID.ts +++ b/src/i18n/locales/id_ID.ts @@ -7,9 +7,9 @@ export const id_ID: LanguageTranslation = { browse: 'Jelajahi', tables: 'Tabel', refs: 'Refs', - areas: 'Area', dependencies: 'Ketergantungan', custom_types: 'Tipe Kustom', + visuals: 'Visual', }, menu: { actions: { @@ -232,6 +232,35 @@ export const id_ID: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visual', + tabs: { + areas: 'Areas', + notes: 'Catatan', + }, + }, + + notes_section: { + filter: 'Filter', + add_note: 'Tambah Catatan', + no_results: 'Tidak ada catatan ditemukan', + clear: 'Hapus Filter', + empty_state: { + title: 'Tidak Ada Catatan', + description: + 'Buat catatan untuk menambahkan anotasi teks di kanvas', + }, + note: { + empty_note: 'Catatan kosong', + note_actions: { + title: 'Aksi Catatan', + edit_content: 'Edit Konten', + delete_note: 'Hapus Catatan', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -484,6 +513,7 @@ export const id_ID: LanguageTranslation = { new_relationship: 'Hubungan Baru', // TODO: Translate new_area: 'New Area', + new_note: 'Catatan Baru', }, table_node_context_menu: { diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index ec82a8e25..92d55efa1 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -7,9 +7,9 @@ export const ja: LanguageTranslation = { browse: '参照', tables: 'テーブル', refs: '参照', - areas: 'エリア', dependencies: '依存関係', custom_types: 'カスタムタイプ', + visuals: 'ビジュアル', }, menu: { actions: { @@ -237,6 +237,35 @@ export const ja: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'ビジュアル', + tabs: { + areas: 'Areas', + notes: 'ノート', + }, + }, + + notes_section: { + filter: 'フィルター', + add_note: 'ノートを追加', + no_results: 'ノートが見つかりません', + clear: 'フィルターをクリア', + empty_state: { + title: 'ノートがありません', + description: + 'キャンバス上にテキスト注釈を追加するためのノートを作成', + }, + note: { + empty_note: '空のノート', + note_actions: { + title: 'ノートアクション', + edit_content: 'コンテンツを編集', + delete_note: 'ノートを削除', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -489,6 +518,7 @@ export const ja: LanguageTranslation = { new_relationship: '新しいリレーションシップ', // TODO: Translate new_area: 'New Area', + new_note: '新しいメモ', }, table_node_context_menu: { diff --git a/src/i18n/locales/ko_KR.ts b/src/i18n/locales/ko_KR.ts index b3d352700..437492eae 100644 --- a/src/i18n/locales/ko_KR.ts +++ b/src/i18n/locales/ko_KR.ts @@ -7,9 +7,9 @@ export const ko_KR: LanguageTranslation = { browse: '찾아보기', tables: '테이블', refs: 'Refs', - areas: '영역', dependencies: '종속성', custom_types: '사용자 지정 타입', + visuals: '시각화', }, menu: { actions: { @@ -232,6 +232,35 @@ export const ko_KR: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: '시각화', + tabs: { + areas: 'Areas', + notes: '메모', + }, + }, + + notes_section: { + filter: '필터', + add_note: '메모 추가', + no_results: '메모를 찾을 수 없습니다', + clear: '필터 지우기', + empty_state: { + title: '메모 없음', + description: + '캔버스에 텍스트 주석을 추가하려면 메모를 만드세요', + }, + note: { + empty_note: '빈 메모', + note_actions: { + title: '메모 작업', + edit_content: '내용 편집', + delete_note: '메모 삭제', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -481,6 +510,7 @@ export const ko_KR: LanguageTranslation = { new_relationship: '새 연관관계', // TODO: Translate new_area: 'New Area', + new_note: '새 메모', }, table_node_context_menu: { diff --git a/src/i18n/locales/mr.ts b/src/i18n/locales/mr.ts index 5648a2009..5fda1e778 100644 --- a/src/i18n/locales/mr.ts +++ b/src/i18n/locales/mr.ts @@ -7,9 +7,9 @@ export const mr: LanguageTranslation = { browse: 'ब्राउज', tables: 'टेबल', refs: 'Refs', - areas: 'क्षेत्रे', dependencies: 'अवलंबने', custom_types: 'कस्टम प्रकार', + visuals: 'Visuals', }, menu: { actions: { @@ -236,6 +236,35 @@ export const mr: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'नोट्स', + }, + }, + + notes_section: { + filter: 'फिल्टर', + add_note: 'नोट जोडा', + no_results: 'कोणत्याही नोट्स सापडल्या नाहीत', + clear: 'फिल्टर साफ करा', + empty_state: { + title: 'नोट्स नाहीत', + description: + 'कॅनव्हासवर मजकूर भाष्य जोडण्यासाठी एक नोट तयार करा', + }, + note: { + empty_note: 'रिकामी नोट', + note_actions: { + title: 'नोट क्रिया', + edit_content: 'सामग्री संपादित करा', + delete_note: 'नोट हटवा', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -493,6 +522,7 @@ export const mr: LanguageTranslation = { new_relationship: 'नवीन रिलेशनशिप', // TODO: Translate new_area: 'New Area', + new_note: 'नवीन टीप', }, table_node_context_menu: { diff --git a/src/i18n/locales/ne.ts b/src/i18n/locales/ne.ts index a5b858c74..6b2c129eb 100644 --- a/src/i18n/locales/ne.ts +++ b/src/i18n/locales/ne.ts @@ -7,9 +7,9 @@ export const ne: LanguageTranslation = { browse: 'ब्राउज', tables: 'टेबलहरू', refs: 'Refs', - areas: 'क्षेत्रहरू', dependencies: 'निर्भरताहरू', custom_types: 'कस्टम प्रकारहरू', + visuals: 'Visuals', }, menu: { actions: { @@ -233,6 +233,35 @@ export const ne: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'टिप्पणीहरू', + }, + }, + + notes_section: { + filter: 'फिल्टर', + add_note: 'टिप्पणी थप्नुहोस्', + no_results: 'कुनै टिप्पणी फेला परेन', + clear: 'फिल्टर खाली गर्नुहोस्', + empty_state: { + title: 'कुनै टिप्पणी छैन', + description: + 'क्यानभासमा पाठ टिप्पणी थप्न टिप्पणी सिर्जना गर्नुहोस्', + }, + note: { + empty_note: 'खाली टिप्पणी', + note_actions: { + title: 'टिप्पणी कार्यहरू', + edit_content: 'सामग्री सम्पादन गर्नुहोस्', + delete_note: 'टिप्पणी मेटाउनुहोस्', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -487,6 +516,7 @@ export const ne: LanguageTranslation = { new_relationship: 'नयाँ सम्बन्ध', // TODO: Translate new_area: 'New Area', + new_note: 'नयाँ नोट', }, table_node_context_menu: { diff --git a/src/i18n/locales/pt_BR.ts b/src/i18n/locales/pt_BR.ts index b16a9c8ec..2605b6a1b 100644 --- a/src/i18n/locales/pt_BR.ts +++ b/src/i18n/locales/pt_BR.ts @@ -7,9 +7,9 @@ export const pt_BR: LanguageTranslation = { browse: 'Navegar', tables: 'Tabelas', refs: 'Refs', - areas: 'Áreas', dependencies: 'Dependências', custom_types: 'Tipos Personalizados', + visuals: 'Visuais', }, menu: { actions: { @@ -233,6 +233,35 @@ export const pt_BR: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuais', + tabs: { + areas: 'Areas', + notes: 'Notas', + }, + }, + + notes_section: { + filter: 'Filtrar', + add_note: 'Adicionar Nota', + no_results: 'Nenhuma nota encontrada', + clear: 'Limpar Filtro', + empty_state: { + title: 'Sem Notas', + description: + 'Crie uma nota para adicionar anotações de texto na tela', + }, + note: { + empty_note: 'Nota vazia', + note_actions: { + title: 'Ações de Nota', + edit_content: 'Editar Conteúdo', + delete_note: 'Excluir Nota', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -486,6 +515,7 @@ export const pt_BR: LanguageTranslation = { new_relationship: 'Novo Relacionamento', // TODO: Translate new_area: 'New Area', + new_note: 'Nova Nota', }, table_node_context_menu: { diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 933b32b29..a92a59353 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -7,9 +7,9 @@ export const ru: LanguageTranslation = { browse: 'Обзор', tables: 'Таблицы', refs: 'Ссылки', - areas: 'Области', dependencies: 'Зависимости', custom_types: 'Пользовательские типы', + visuals: 'Визуальные элементы', }, menu: { actions: { @@ -230,6 +230,35 @@ export const ru: LanguageTranslation = { description: 'Создайте область, чтобы начать', }, }, + + visuals_section: { + visuals: 'Визуальные элементы', + tabs: { + areas: 'Области', + notes: 'Заметки', + }, + }, + + notes_section: { + filter: 'Фильтр', + add_note: 'Добавить Заметку', + no_results: 'Заметки не найдены', + clear: 'Очистить Фильтр', + empty_state: { + title: 'Нет Заметок', + description: + 'Создайте заметку, чтобы добавить текстовые аннотации на холсте', + }, + note: { + empty_note: 'Пустая заметка', + note_actions: { + title: 'Действия с Заметкой', + edit_content: 'Редактировать Содержимое', + delete_note: 'Удалить Заметку', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -481,6 +510,7 @@ export const ru: LanguageTranslation = { new_view: 'Новое представление', new_relationship: 'Создать отношение', new_area: 'Новая область', + new_note: 'Новая Заметка', }, table_node_context_menu: { diff --git a/src/i18n/locales/te.ts b/src/i18n/locales/te.ts index e26d3a724..6e3887df3 100644 --- a/src/i18n/locales/te.ts +++ b/src/i18n/locales/te.ts @@ -7,9 +7,9 @@ export const te: LanguageTranslation = { browse: 'బ్రాఉజ్', tables: 'టేబల్లు', refs: 'సంబంధాలు', - areas: 'ప్రదేశాలు', dependencies: 'ఆధారతలు', custom_types: 'కస్టమ్ టైప్స్', + visuals: 'Visuals', }, menu: { actions: { @@ -234,6 +234,35 @@ export const te: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Visuals', + tabs: { + areas: 'Areas', + notes: 'గమనికలు', + }, + }, + + notes_section: { + filter: 'ఫిల్టర్', + add_note: 'గమనిక జోడించండి', + no_results: 'గమనికలు కనుగొనబడలేదు', + clear: 'ఫిల్టర్‌ను క్లియర్ చేయండి', + empty_state: { + title: 'గమనికలు లేవు', + description: + 'కాన్వాస్‌పై టెక్స్ట్ ఉల్లేఖనలను జోడించడానికి ఒక గమనికను సృష్టించండి', + }, + note: { + empty_note: 'ఖాళీ గమనిక', + note_actions: { + title: 'గమనిక చర్యలు', + edit_content: 'కంటెంట్‌ను సవరించండి', + delete_note: 'గమనికను తొలగించండి', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -490,6 +519,7 @@ export const te: LanguageTranslation = { new_relationship: 'కొత్త సంబంధం', // TODO: Translate new_area: 'New Area', + new_note: 'కొత్త నోట్', }, table_node_context_menu: { diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 5029a671e..ea6a13061 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -7,9 +7,9 @@ export const tr: LanguageTranslation = { browse: 'Gözat', tables: 'Tablolar', refs: 'Refs', - areas: 'Alanlar', dependencies: 'Bağımlılıklar', custom_types: 'Özel Tipler', + visuals: 'Görseller', }, menu: { actions: { @@ -233,6 +233,35 @@ export const tr: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Görseller', + tabs: { + areas: 'Areas', + notes: 'Notlar', + }, + }, + + notes_section: { + filter: 'Filtrele', + add_note: 'Not Ekle', + no_results: 'Not bulunamadı', + clear: 'Filtreyi Temizle', + empty_state: { + title: 'Not Yok', + description: + 'Tuval üzerinde metin açıklamaları eklemek için bir not oluşturun', + }, + note: { + empty_note: 'Boş not', + note_actions: { + title: 'Not İşlemleri', + edit_content: 'İçeriği Düzenle', + delete_note: 'Notu Sil', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -475,6 +504,7 @@ export const tr: LanguageTranslation = { new_relationship: 'Yeni İlişki', // TODO: Translate new_area: 'New Area', + new_note: 'Yeni Not', }, table_node_context_menu: { edit_table: 'Tabloyu Düzenle', diff --git a/src/i18n/locales/uk.ts b/src/i18n/locales/uk.ts index f7fa03ed7..2b8b87b13 100644 --- a/src/i18n/locales/uk.ts +++ b/src/i18n/locales/uk.ts @@ -7,9 +7,9 @@ export const uk: LanguageTranslation = { browse: 'Огляд', tables: 'Таблиці', refs: 'Зв’язки', - areas: 'Області', dependencies: 'Залежності', custom_types: 'Користувацькі типи', + visuals: 'Візуальні елементи', }, menu: { actions: { @@ -231,6 +231,35 @@ export const uk: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Візуальні елементи', + tabs: { + areas: 'Areas', + notes: 'Нотатки', + }, + }, + + notes_section: { + filter: 'Фільтр', + add_note: 'Додати Нотатку', + no_results: 'Нотатки не знайдено', + clear: 'Очистити Фільтр', + empty_state: { + title: 'Немає Нотаток', + description: + 'Створіть нотатку, щоб додати текстові анотації на полотні', + }, + note: { + empty_note: 'Порожня нотатка', + note_actions: { + title: 'Дії з Нотаткою', + edit_content: 'Редагувати Вміст', + delete_note: 'Видалити Нотатку', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -481,6 +510,7 @@ export const uk: LanguageTranslation = { new_relationship: 'Новий звʼязок', // TODO: Translate new_area: 'New Area', + new_note: 'Нова Нотатка', }, table_node_context_menu: { diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index 52695c073..afa9b93e6 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -7,9 +7,9 @@ export const vi: LanguageTranslation = { browse: 'Duyệt', tables: 'Bảng', refs: 'Refs', - areas: 'Khu vực', dependencies: 'Phụ thuộc', custom_types: 'Kiểu tùy chỉnh', + visuals: 'Hình ảnh', }, menu: { actions: { @@ -232,6 +232,35 @@ export const vi: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: 'Hình ảnh', + tabs: { + areas: 'Areas', + notes: 'Ghi chú', + }, + }, + + notes_section: { + filter: 'Lọc', + add_note: 'Thêm Ghi Chú', + no_results: 'Không tìm thấy ghi chú', + clear: 'Xóa Bộ Lọc', + empty_state: { + title: 'Không Có Ghi Chú', + description: + 'Tạo ghi chú để thêm chú thích văn bản trên canvas', + }, + note: { + empty_note: 'Ghi chú trống', + note_actions: { + title: 'Hành Động Ghi Chú', + edit_content: 'Chỉnh Sửa Nội Dung', + delete_note: 'Xóa Ghi Chú', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -482,6 +511,7 @@ export const vi: LanguageTranslation = { new_relationship: 'Tạo quan hệ mới', // TODO: Translate new_area: 'New Area', + new_note: 'Ghi Chú Mới', }, table_node_context_menu: { diff --git a/src/i18n/locales/zh_CN.ts b/src/i18n/locales/zh_CN.ts index 0cf6b51dc..3e847dda4 100644 --- a/src/i18n/locales/zh_CN.ts +++ b/src/i18n/locales/zh_CN.ts @@ -7,9 +7,9 @@ export const zh_CN: LanguageTranslation = { browse: '浏览', tables: '表', refs: '引用', - areas: '区域', dependencies: '依赖关系', custom_types: '自定义类型', + visuals: '视觉效果', }, menu: { actions: { @@ -229,6 +229,34 @@ export const zh_CN: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: '视觉效果', + tabs: { + areas: 'Areas', + notes: '笔记', + }, + }, + + notes_section: { + filter: '筛选', + add_note: '添加笔记', + no_results: '未找到笔记', + clear: '清除筛选', + empty_state: { + title: '没有笔记', + description: '创建笔记以在画布上添加文本注释', + }, + note: { + empty_note: '空笔记', + note_actions: { + title: '笔记操作', + edit_content: '编辑内容', + delete_note: '删除笔记', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -477,6 +505,7 @@ export const zh_CN: LanguageTranslation = { new_relationship: '新建关系', // TODO: Translate new_area: 'New Area', + new_note: '新笔记', }, table_node_context_menu: { diff --git a/src/i18n/locales/zh_TW.ts b/src/i18n/locales/zh_TW.ts index 56ea91fb8..3bbaf39cd 100644 --- a/src/i18n/locales/zh_TW.ts +++ b/src/i18n/locales/zh_TW.ts @@ -7,9 +7,9 @@ export const zh_TW: LanguageTranslation = { browse: '瀏覽', tables: '表格', refs: 'Refs', - areas: '區域', dependencies: '相依性', custom_types: '自定義類型', + visuals: '視覺效果', }, menu: { actions: { @@ -229,6 +229,34 @@ export const zh_TW: LanguageTranslation = { description: 'Create an area to get started', }, }, + + visuals_section: { + visuals: '視覺效果', + tabs: { + areas: 'Areas', + notes: '筆記', + }, + }, + + notes_section: { + filter: '篩選', + add_note: '新增筆記', + no_results: '未找到筆記', + clear: '清除篩選', + empty_state: { + title: '沒有筆記', + description: '建立筆記以在畫布上新增文字註解', + }, + note: { + empty_note: '空白筆記', + note_actions: { + title: '筆記操作', + edit_content: '編輯內容', + delete_note: '刪除筆記', + }, + }, + }, + // TODO: Translate custom_types_section: { custom_types: 'Custom Types', @@ -477,6 +505,7 @@ export const zh_TW: LanguageTranslation = { new_relationship: '新建關聯', // TODO: Translate new_area: 'New Area', + new_note: '新筆記', }, table_node_context_menu: { diff --git a/src/lib/clone.ts b/src/lib/clone.ts index 2050764ef..e7163c50b 100644 --- a/src/lib/clone.ts +++ b/src/lib/clone.ts @@ -6,6 +6,7 @@ import type { DBIndex } from './domain/db-index'; import type { DBRelationship } from './domain/db-relationship'; import type { DBTable } from './domain/db-table'; import type { Diagram } from './domain/diagram'; +import type { Note } from './domain/note'; import { generateId as defaultGenerateId } from './utils'; const generateIdsMapFromTable = ( @@ -49,6 +50,10 @@ const generateIdsMapFromDiagram = ( idsMap.set(area.id, generateId()); }); + diagram.notes?.forEach((note) => { + idsMap.set(note.id, generateId()); + }); + diagram.customTypes?.forEach((customType) => { idsMap.set(customType.id, generateId()); }); @@ -218,6 +223,21 @@ export const cloneDiagram = ( }) .filter((area): area is Area => area !== null) ?? []; + const notes: Note[] = + diagram.notes + ?.map((note) => { + const id = getNewId(note.id); + if (!id) { + return null; + } + + return { + ...note, + id, + } satisfies Note; + }) + .filter((note): note is Note => note !== null) ?? []; + const customTypes: DBCustomType[] = diagram.customTypes ?.map((customType) => { @@ -242,6 +262,7 @@ export const cloneDiagram = ( relationships, tables, areas, + notes, customTypes, createdAt: diagram.createdAt ? new Date(diagram.createdAt) diff --git a/src/lib/domain/diagram.ts b/src/lib/domain/diagram.ts index a0548e418..c53e39cce 100644 --- a/src/lib/domain/diagram.ts +++ b/src/lib/domain/diagram.ts @@ -10,6 +10,8 @@ import { dbTableSchema } from './db-table'; import { areaSchema, type Area } from './area'; import type { DBCustomType } from './db-custom-type'; import { dbCustomTypeSchema } from './db-custom-type'; +import type { Note } from './note'; +import { noteSchema } from './note'; export interface Diagram { id: string; @@ -21,6 +23,7 @@ export interface Diagram { dependencies?: DBDependency[]; areas?: Area[]; customTypes?: DBCustomType[]; + notes?: Note[]; createdAt: Date; updatedAt: Date; } @@ -35,6 +38,7 @@ export const diagramSchema: z.ZodType = z.object({ dependencies: z.array(dbDependencySchema).optional(), areas: z.array(areaSchema).optional(), customTypes: z.array(dbCustomTypeSchema).optional(), + notes: z.array(noteSchema).optional(), createdAt: z.date(), updatedAt: z.date(), }); diff --git a/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts b/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts index b161b27e9..e5d0da2a9 100644 --- a/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts +++ b/src/lib/domain/diff/diff-check/__tests__/diff-check.test.ts @@ -6,10 +6,12 @@ import type { DBField } from '@/lib/domain/db-field'; import type { DBIndex } from '@/lib/domain/db-index'; import type { DBRelationship } from '@/lib/domain/db-relationship'; import type { Area } from '@/lib/domain/area'; +import type { Note } from '@/lib/domain/note'; import { DatabaseType } from '@/lib/domain/database-type'; import type { TableDiffChanged } from '../../table-diff'; import type { FieldDiffChanged } from '../../field-diff'; import type { AreaDiffChanged } from '../../area-diff'; +import type { NoteDiffChanged } from '../../note-diff'; // Helper function to create a mock diagram function createMockDiagram(overrides?: Partial): Diagram { @@ -81,6 +83,20 @@ function createMockArea(overrides?: Partial): Area { } as Area; } +// Helper function to create a mock note +function createMockNote(overrides?: Partial): Note { + return { + id: 'note-1', + content: 'Test note content', + x: 0, + y: 0, + width: 200, + height: 150, + color: '#3b82f6', + ...overrides, + } as Note; +} + describe('generateDiff', () => { describe('Basic Table Diffing', () => { it('should detect added tables', () => { @@ -466,6 +482,408 @@ describe('generateDiff', () => { }); }); + describe('Note Diffing', () => { + it('should detect added notes when includeNotes is true', () => { + const oldDiagram = createMockDiagram({ notes: [] }); + const newDiagram = createMockDiagram({ + notes: [createMockNote()], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('added'); + expect(result.changedNotes.has('note-1')).toBe(true); + }); + + it('should not detect note changes when includeNotes is false', () => { + const oldDiagram = createMockDiagram({ notes: [] }); + const newDiagram = createMockDiagram({ + notes: [createMockNote()], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: false, + }, + }); + + expect(result.diffMap.size).toBe(0); + expect(result.changedNotes.size).toBe(0); + }); + + it('should detect removed notes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote()], + }); + const newDiagram = createMockDiagram({ notes: [] }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('removed'); + expect(result.changedNotes.has('note-1')).toBe(true); + }); + + it('should detect note content changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ content: 'Old content' })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ content: 'New content' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-content-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as NoteDiffChanged)?.attribute).toBe('content'); + expect((diff as NoteDiffChanged)?.oldValue).toBe('Old content'); + expect((diff as NoteDiffChanged)?.newValue).toBe('New content'); + }); + + it('should detect note color changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ color: '#3b82f6' })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ color: '#ef4444' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-color-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as NoteDiffChanged)?.attribute).toBe('color'); + expect((diff as NoteDiffChanged)?.oldValue).toBe('#3b82f6'); + expect((diff as NoteDiffChanged)?.newValue).toBe('#ef4444'); + }); + + it('should detect note position changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ x: 0, y: 0 })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ x: 100, y: 200 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['content', 'color', 'x', 'y'], + }, + }, + }); + + expect(result.diffMap.size).toBe(2); + expect(result.diffMap.has('note-x-note-1')).toBe(true); + expect(result.diffMap.has('note-y-note-1')).toBe(true); + + const xDiff = result.diffMap.get('note-x-note-1'); + expect((xDiff as NoteDiffChanged)?.oldValue).toBe(0); + expect((xDiff as NoteDiffChanged)?.newValue).toBe(100); + }); + + it('should detect note width changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ width: 200 })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ width: 300 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['width'], + }, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-width-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as NoteDiffChanged)?.attribute).toBe('width'); + expect((diff as NoteDiffChanged)?.oldValue).toBe(200); + expect((diff as NoteDiffChanged)?.newValue).toBe(300); + }); + + it('should detect note height changes', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ height: 150 })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ height: 250 })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['height'], + }, + }, + }); + + expect(result.diffMap.size).toBe(1); + const diff = result.diffMap.get('note-height-note-1'); + expect(diff).toBeDefined(); + expect(diff?.type).toBe('changed'); + expect((diff as NoteDiffChanged)?.attribute).toBe('height'); + expect((diff as NoteDiffChanged)?.oldValue).toBe(150); + expect((diff as NoteDiffChanged)?.newValue).toBe(250); + }); + + it('should detect multiple note dimension changes', () => { + const oldDiagram = createMockDiagram({ + notes: [ + createMockNote({ x: 0, y: 0, width: 200, height: 150 }), + ], + }); + const newDiagram = createMockDiagram({ + notes: [ + createMockNote({ x: 50, y: 75, width: 300, height: 250 }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['x', 'y', 'width', 'height'], + }, + }, + }); + + expect(result.diffMap.size).toBe(4); + expect(result.diffMap.has('note-x-note-1')).toBe(true); + expect(result.diffMap.has('note-y-note-1')).toBe(true); + expect(result.diffMap.has('note-width-note-1')).toBe(true); + expect(result.diffMap.has('note-height-note-1')).toBe(true); + + const widthDiff = result.diffMap.get('note-width-note-1'); + expect((widthDiff as NoteDiffChanged)?.oldValue).toBe(200); + expect((widthDiff as NoteDiffChanged)?.newValue).toBe(300); + + const heightDiff = result.diffMap.get('note-height-note-1'); + expect((heightDiff as NoteDiffChanged)?.oldValue).toBe(150); + expect((heightDiff as NoteDiffChanged)?.newValue).toBe(250); + }); + + it('should detect multiple notes with different changes', () => { + const oldDiagram = createMockDiagram({ + notes: [ + createMockNote({ id: 'note-1', content: 'Note 1' }), + createMockNote({ id: 'note-2', content: 'Note 2' }), + createMockNote({ id: 'note-3', content: 'Note 3' }), + ], + }); + const newDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-1', + content: 'Note 1 Updated', + }), // Changed + createMockNote({ id: 'note-2', content: 'Note 2' }), // Unchanged + // note-3 removed + createMockNote({ id: 'note-4', content: 'Note 4' }), // Added + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + }, + }); + + // Should detect: 1 content change, 1 removal, 1 addition + expect(result.diffMap.has('note-content-note-1')).toBe(true); // Changed + expect(result.diffMap.has('note-note-3')).toBe(true); // Removed + expect(result.diffMap.has('note-note-4')).toBe(true); // Added + + expect(result.changedNotes.has('note-1')).toBe(true); + expect(result.changedNotes.has('note-3')).toBe(true); + expect(result.changedNotes.has('note-4')).toBe(true); + }); + + it('should use custom note matcher', () => { + const oldDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-1', + content: 'Unique content', + color: '#3b82f6', + }), + ], + }); + const newDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-2', + content: 'Unique content', + color: '#ef4444', + }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + matchers: { + note: (note, notes) => + notes.find((n) => n.content === note.content), + }, + }, + }); + + // With content-based matching, note-1 should match note-2 by content + // and detect the color change + const colorChange = result.diffMap.get('note-color-note-1'); + expect(colorChange).toBeDefined(); + expect(colorChange?.type).toBe('changed'); + expect((colorChange as NoteDiffChanged)?.attribute).toBe('color'); + expect((colorChange as NoteDiffChanged)?.oldValue).toBe('#3b82f6'); + expect((colorChange as NoteDiffChanged)?.newValue).toBe('#ef4444'); + }); + + it('should only check specified note change types', () => { + const oldDiagram = createMockDiagram({ + notes: [createMockNote({ id: 'note-1', content: 'Note 1' })], + }); + const newDiagram = createMockDiagram({ + notes: [createMockNote({ id: 'note-2', content: 'Note 2' })], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + changeTypes: { + notes: ['added'], // Only check for added notes + }, + }, + }); + + // Should only detect added note (note-2) + const addedNotes = Array.from(result.diffMap.values()).filter( + (diff) => diff.type === 'added' && diff.object === 'note' + ); + expect(addedNotes.length).toBe(1); + + // Should not detect removed note (note-1) + const removedNotes = Array.from(result.diffMap.values()).filter( + (diff) => diff.type === 'removed' && diff.object === 'note' + ); + expect(removedNotes.length).toBe(0); + }); + + it('should only check specified note attributes', () => { + const oldDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-1', + content: 'Old content', + color: '#3b82f6', + x: 0, + y: 0, + }), + ], + }); + const newDiagram = createMockDiagram({ + notes: [ + createMockNote({ + id: 'note-1', + content: 'New content', + color: '#ef4444', + x: 100, + y: 200, + }), + ], + }); + + const result = generateDiff({ + diagram: oldDiagram, + newDiagram, + options: { + includeNotes: true, + attributes: { + notes: ['content'], // Only check content changes + }, + }, + }); + + // Should only detect content change + const contentChanges = Array.from(result.diffMap.values()).filter( + (diff) => + diff.type === 'changed' && + diff.attribute === 'content' && + diff.object === 'note' + ); + expect(contentChanges.length).toBe(1); + + // Should not detect color or position changes + const otherChanges = Array.from(result.diffMap.values()).filter( + (diff) => + diff.type === 'changed' && + (diff.attribute === 'color' || + diff.attribute === 'x' || + diff.attribute === 'y') && + diff.object === 'note' + ); + expect(otherChanges.length).toBe(0); + }); + }); + describe('Custom Matchers', () => { it('should use custom table matcher to match by name', () => { const oldDiagram = createMockDiagram({ @@ -708,7 +1126,7 @@ describe('generateDiff', () => { }); describe('Complex Scenarios', () => { - it('should detect all dimensional changes for tables and areas', () => { + it('should detect all dimensional changes for tables, areas, and notes', () => { const oldDiagram = createMockDiagram({ tables: [ createMockTable({ @@ -727,6 +1145,15 @@ describe('generateDiff', () => { height: 150, }), ], + notes: [ + createMockNote({ + id: 'note-1', + x: 0, + y: 0, + width: 300, + height: 200, + }), + ], }); const newDiagram = createMockDiagram({ @@ -747,6 +1174,15 @@ describe('generateDiff', () => { height: 175, }), ], + notes: [ + createMockNote({ + id: 'note-1', + x: 40, + y: 50, + width: 350, + height: 225, + }), + ], }); const result = generateDiff({ @@ -754,9 +1190,11 @@ describe('generateDiff', () => { newDiagram, options: { includeAreas: true, + includeNotes: true, attributes: { tables: ['x', 'y', 'width'], areas: ['x', 'y', 'width', 'height'], + notes: ['x', 'y', 'width', 'height'], }, }, }); @@ -772,6 +1210,12 @@ describe('generateDiff', () => { expect(result.diffMap.has('area-width-area-1')).toBe(true); expect(result.diffMap.has('area-height-area-1')).toBe(true); + // Note dimensional changes + expect(result.diffMap.has('note-x-note-1')).toBe(true); + expect(result.diffMap.has('note-y-note-1')).toBe(true); + expect(result.diffMap.has('note-width-note-1')).toBe(true); + expect(result.diffMap.has('note-height-note-1')).toBe(true); + // Verify the correct values const tableWidthDiff = result.diffMap.get('table-width-table-1'); expect((tableWidthDiff as TableDiffChanged)?.oldValue).toBe(100); @@ -784,6 +1228,14 @@ describe('generateDiff', () => { const areaHeightDiff = result.diffMap.get('area-height-area-1'); expect((areaHeightDiff as AreaDiffChanged)?.oldValue).toBe(150); expect((areaHeightDiff as AreaDiffChanged)?.newValue).toBe(175); + + const noteWidthDiff = result.diffMap.get('note-width-note-1'); + expect((noteWidthDiff as NoteDiffChanged)?.oldValue).toBe(300); + expect((noteWidthDiff as NoteDiffChanged)?.newValue).toBe(350); + + const noteHeightDiff = result.diffMap.get('note-height-note-1'); + expect((noteHeightDiff as NoteDiffChanged)?.oldValue).toBe(200); + expect((noteHeightDiff as NoteDiffChanged)?.newValue).toBe(225); }); it('should handle multiple simultaneous changes', () => { @@ -852,6 +1304,7 @@ describe('generateDiff', () => { expect(result.changedTables.size).toBe(0); expect(result.changedFields.size).toBe(0); expect(result.changedAreas.size).toBe(0); + expect(result.changedNotes.size).toBe(0); }); it('should handle diagrams with undefined collections', () => { @@ -859,11 +1312,13 @@ describe('generateDiff', () => { tables: undefined, relationships: undefined, areas: undefined, + notes: undefined, }); const diagram2 = createMockDiagram({ tables: [createMockTable({ id: 'table-1' })], relationships: [createMockRelationship({ id: 'rel-1' })], areas: [createMockArea({ id: 'area-1' })], + notes: [createMockNote({ id: 'note-1' })], }); const result = generateDiff({ @@ -871,6 +1326,7 @@ describe('generateDiff', () => { newDiagram: diagram2, options: { includeAreas: true, + includeNotes: true, }, }); @@ -878,6 +1334,7 @@ describe('generateDiff', () => { expect(result.diffMap.has('table-table-1')).toBe(true); expect(result.diffMap.has('relationship-rel-1')).toBe(true); expect(result.diffMap.has('area-area-1')).toBe(true); + expect(result.diffMap.has('note-note-1')).toBe(true); }); }); }); diff --git a/src/lib/domain/diff/diff-check/diff-check.ts b/src/lib/domain/diff/diff-check/diff-check.ts index e82658ca4..087be1ad3 100644 --- a/src/lib/domain/diff/diff-check/diff-check.ts +++ b/src/lib/domain/diff/diff-check/diff-check.ts @@ -4,6 +4,7 @@ import type { DBIndex } from '@/lib/domain/db-index'; import type { DBTable } from '@/lib/domain/db-table'; import type { DBRelationship } from '@/lib/domain/db-relationship'; import type { Area } from '@/lib/domain/area'; +import type { Note } from '@/lib/domain/note'; import type { ChartDBDiff, DiffMap, DiffObject } from '@/lib/domain/diff/diff'; import type { FieldDiff, @@ -11,6 +12,7 @@ import type { } from '@/lib/domain/diff/field-diff'; import type { TableDiff, TableDiffAttribute } from '../table-diff'; import type { AreaDiff, AreaDiffAttribute } from '../area-diff'; +import type { NoteDiff, NoteDiffAttribute } from '../note-diff'; import type { IndexDiff } from '../index-diff'; import type { RelationshipDiff } from '../relationship-diff'; @@ -44,10 +46,12 @@ export interface GenerateDiffOptions { includeIndexes?: boolean; includeRelationships?: boolean; includeAreas?: boolean; + includeNotes?: boolean; attributes?: { tables?: TableDiffAttribute[]; fields?: FieldDiffAttribute[]; areas?: AreaDiffAttribute[]; + notes?: NoteDiffAttribute[]; }; changeTypes?: { tables?: TableDiff['type'][]; @@ -55,6 +59,7 @@ export interface GenerateDiffOptions { indexes?: IndexDiff['type'][]; relationships?: RelationshipDiff['type'][]; areas?: AreaDiff['type'][]; + notes?: NoteDiff['type'][]; }; matchers?: { table?: (table: DBTable, tables: DBTable[]) => DBTable | undefined; @@ -65,6 +70,7 @@ export interface GenerateDiffOptions { relationships: DBRelationship[] ) => DBRelationship | undefined; area?: (area: Area, areas: Area[]) => Area | undefined; + note?: (note: Note, notes: Note[]) => Note | undefined; }; } @@ -81,6 +87,7 @@ export function generateDiff({ changedTables: Map; changedFields: Map; changedAreas: Map; + changedNotes: Map; } { // Merge with default options const mergedOptions: GenerateDiffOptions = { @@ -89,6 +96,7 @@ export function generateDiff({ includeIndexes: options.includeIndexes ?? true, includeRelationships: options.includeRelationships ?? true, includeAreas: options.includeAreas ?? false, + includeNotes: options.includeNotes ?? false, attributes: options.attributes ?? {}, changeTypes: options.changeTypes ?? {}, matchers: options.matchers ?? {}, @@ -98,6 +106,7 @@ export function generateDiff({ const changedTables = new Map(); const changedFields = new Map(); const changedAreas = new Map(); + const changedNotes = new Map(); // Use provided matchers or default ones const tableMatcher = mergedOptions.matchers?.table ?? defaultTableMatcher; @@ -106,6 +115,7 @@ export function generateDiff({ const relationshipMatcher = mergedOptions.matchers?.relationship ?? defaultRelationshipMatcher; const areaMatcher = mergedOptions.matchers?.area ?? defaultAreaMatcher; + const noteMatcher = mergedOptions.matchers?.note ?? defaultNoteMatcher; // Compare tables if (mergedOptions.includeTables) { @@ -157,7 +167,26 @@ export function generateDiff({ }); } - return { diffMap: newDiffs, changedTables, changedFields, changedAreas }; + // Compare notes if enabled + if (mergedOptions.includeNotes) { + compareNotes({ + diagram, + newDiagram, + diffMap: newDiffs, + changedNotes, + attributes: mergedOptions.attributes?.notes, + changeTypes: mergedOptions.changeTypes?.notes, + noteMatcher, + }); + } + + return { + diffMap: newDiffs, + changedTables, + changedFields, + changedAreas, + changedNotes, + }; } // Compare tables between diagrams @@ -1019,6 +1048,217 @@ function compareAreas({ } } +// Compare notes between diagrams +function compareNotes({ + diagram, + newDiagram, + diffMap, + changedNotes, + attributes, + changeTypes, + noteMatcher, +}: { + diagram: Diagram; + newDiagram: Diagram; + diffMap: DiffMap; + changedNotes: Map; + attributes?: NoteDiffAttribute[]; + changeTypes?: NoteDiff['type'][]; + noteMatcher: (note: Note, notes: Note[]) => Note | undefined; +}) { + const oldNotes = diagram.notes || []; + const newNotes = newDiagram.notes || []; + + // If changeTypes is empty array, don't check any changes + if (changeTypes && changeTypes.length === 0) { + return; + } + + // If changeTypes is undefined, check all types + const typesToCheck = changeTypes ?? ['added', 'removed', 'changed']; + + // Check for added notes + if (typesToCheck.includes('added')) { + for (const newNote of newNotes) { + if (!noteMatcher(newNote, oldNotes)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: newNote.id, + }), + { + object: 'note', + type: 'added', + noteAdded: newNote, + } + ); + changedNotes.set(newNote.id, true); + } + } + } + + // Check for removed notes + if (typesToCheck.includes('removed')) { + for (const oldNote of oldNotes) { + if (!noteMatcher(oldNote, newNotes)) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + }), + { + object: 'note', + type: 'removed', + noteId: oldNote.id, + } + ); + changedNotes.set(oldNote.id, true); + } + } + } + + // Check for note content and color changes + if (typesToCheck.includes('changed')) { + for (const oldNote of oldNotes) { + const newNote = noteMatcher(oldNote, newNotes); + + if (!newNote) continue; + + // If attributes are specified, only check those attributes + const attributesToCheck: NoteDiffAttribute[] = attributes ?? [ + 'content', + 'color', + ]; + + if ( + attributesToCheck.includes('content') && + oldNote.content !== newNote.content + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'content', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'content', + newValue: newNote.content, + oldValue: oldNote.content, + } + ); + changedNotes.set(oldNote.id, true); + } + + if ( + attributesToCheck.includes('color') && + oldNote.color !== newNote.color + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'color', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'color', + newValue: newNote.color, + oldValue: oldNote.color, + } + ); + changedNotes.set(oldNote.id, true); + } + + if (attributesToCheck.includes('x') && oldNote.x !== newNote.x) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'x', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'x', + newValue: newNote.x, + oldValue: oldNote.x, + } + ); + changedNotes.set(oldNote.id, true); + } + + if (attributesToCheck.includes('y') && oldNote.y !== newNote.y) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'y', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'y', + newValue: newNote.y, + oldValue: oldNote.y, + } + ); + changedNotes.set(oldNote.id, true); + } + + if ( + attributesToCheck.includes('width') && + oldNote.width !== newNote.width + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'width', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'width', + newValue: newNote.width, + oldValue: oldNote.width, + } + ); + changedNotes.set(oldNote.id, true); + } + + if ( + attributesToCheck.includes('height') && + oldNote.height !== newNote.height + ) { + diffMap.set( + getDiffMapKey({ + diffObject: 'note', + objectId: oldNote.id, + attribute: 'height', + }), + { + object: 'note', + type: 'changed', + noteId: oldNote.id, + attribute: 'height', + newValue: newNote.height, + oldValue: oldNote.height, + } + ); + changedNotes.set(oldNote.id, true); + } + } + } +} + const defaultTableMatcher = ( table: DBTable, tables: DBTable[] @@ -1050,3 +1290,7 @@ const defaultRelationshipMatcher = ( const defaultAreaMatcher = (area: Area, areas: Area[]): Area | undefined => { return areas.find((a) => a.id === area.id); }; + +const defaultNoteMatcher = (note: Note, notes: Note[]): Note | undefined => { + return notes.find((n) => n.id === note.id); +}; diff --git a/src/lib/domain/diff/diff.ts b/src/lib/domain/diff/diff.ts index 524214cde..faab4cd75 100644 --- a/src/lib/domain/diff/diff.ts +++ b/src/lib/domain/diff/diff.ts @@ -10,7 +10,9 @@ import type { TableDiff } from './table-diff'; import { createTableDiffSchema } from './table-diff'; import type { AreaDiff } from './area-diff'; import { createAreaDiffSchema } from './area-diff'; -import type { DBField, DBIndex, DBRelationship, DBTable, Area } from '..'; +import type { NoteDiff } from './note-diff'; +import { createNoteDiffSchema } from './note-diff'; +import type { DBField, DBIndex, DBRelationship, DBTable, Area, Note } from '..'; export type ChartDBDiff< TTable = DBTable, @@ -18,12 +20,14 @@ export type ChartDBDiff< TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, + TNote = Note, > = | TableDiff | FieldDiff | IndexDiff | RelationshipDiff - | AreaDiff; + | AreaDiff + | NoteDiff; export const createChartDBDiffSchema = < TTable = DBTable, @@ -31,20 +35,27 @@ export const createChartDBDiffSchema = < TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, + TNote = Note, >( tableSchema: z.ZodType, fieldSchema: z.ZodType, indexSchema: z.ZodType, relationshipSchema: z.ZodType, - areaSchema: z.ZodType -): z.ZodType> => { + areaSchema: z.ZodType, + noteSchema: z.ZodType +): z.ZodType< + ChartDBDiff +> => { return z.union([ createTableDiffSchema(tableSchema), createFieldDiffSchema(fieldSchema), createIndexDiffSchema(indexSchema), createRelationshipDiffSchema(relationshipSchema), createAreaDiffSchema(areaSchema), - ]) as z.ZodType>; + createNoteDiffSchema(noteSchema), + ]) as z.ZodType< + ChartDBDiff + >; }; export type DiffMap< @@ -53,7 +64,11 @@ export type DiffMap< TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, -> = Map>; + TNote = Note, +> = Map< + string, + ChartDBDiff +>; export type DiffObject< TTable = DBTable, @@ -61,12 +76,14 @@ export type DiffObject< TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, + TNote = Note, > = | TableDiff['object'] | FieldDiff['object'] | IndexDiff['object'] | RelationshipDiff['object'] - | AreaDiff['object']; + | AreaDiff['object'] + | NoteDiff['object']; type ExtractDiffKind = T extends { object: infer O; type: infer Type } ? T extends { attribute: infer A } @@ -80,7 +97,10 @@ export type DiffKind< TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, -> = ExtractDiffKind>; + TNote = Note, +> = ExtractDiffKind< + ChartDBDiff +>; export const isDiffOfKind = < TTable = DBTable, @@ -88,9 +108,10 @@ export const isDiffOfKind = < TIndex = DBIndex, TRelationship = DBRelationship, TArea = Area, + TNote = Note, >( - diff: ChartDBDiff, - kind: DiffKind + diff: ChartDBDiff, + kind: DiffKind ): boolean => { if ('attribute' in kind) { return ( diff --git a/src/lib/domain/diff/note-diff.ts b/src/lib/domain/diff/note-diff.ts new file mode 100644 index 000000000..5cebb1fcf --- /dev/null +++ b/src/lib/domain/diff/note-diff.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import type { Note } from '../note'; + +export type NoteDiffAttribute = keyof Pick< + Note, + 'content' | 'color' | 'x' | 'y' | 'width' | 'height' +>; + +const noteDiffAttributeSchema: z.ZodType = z.union([ + z.literal('content'), + z.literal('color'), + z.literal('x'), + z.literal('y'), + z.literal('width'), + z.literal('height'), +]); + +export interface NoteDiffChanged { + object: 'note'; + type: 'changed'; + noteId: string; + attribute: NoteDiffAttribute; + oldValue?: string | number | null; + newValue?: string | number | null; +} + +export const NoteDiffChangedSchema: z.ZodType = z.object({ + object: z.literal('note'), + type: z.literal('changed'), + noteId: z.string(), + attribute: noteDiffAttributeSchema, + oldValue: z.union([z.string(), z.number(), z.null()]).optional(), + newValue: z.union([z.string(), z.number(), z.null()]).optional(), +}); + +export interface NoteDiffRemoved { + object: 'note'; + type: 'removed'; + noteId: string; +} + +export const NoteDiffRemovedSchema: z.ZodType = z.object({ + object: z.literal('note'), + type: z.literal('removed'), + noteId: z.string(), +}); + +export interface NoteDiffAdded { + object: 'note'; + type: 'added'; + noteAdded: T; +} + +export const createNoteDiffAddedSchema = ( + noteSchema: z.ZodType +): z.ZodType> => { + return z.object({ + object: z.literal('note'), + type: z.literal('added'), + noteAdded: noteSchema, + }) as z.ZodType>; +}; + +export type NoteDiff = + | NoteDiffChanged + | NoteDiffRemoved + | NoteDiffAdded; + +export const createNoteDiffSchema = ( + noteSchema: z.ZodType +): z.ZodType> => { + return z.union([ + NoteDiffChangedSchema, + NoteDiffRemovedSchema, + createNoteDiffAddedSchema(noteSchema), + ]) as z.ZodType>; +}; diff --git a/src/lib/domain/index.ts b/src/lib/domain/index.ts index c7e874b8a..456f2e887 100644 --- a/src/lib/domain/index.ts +++ b/src/lib/domain/index.ts @@ -11,3 +11,4 @@ export * from './db-relationship'; export * from './db-schema'; export * from './db-table'; export * from './diagram'; +export * from './note'; diff --git a/src/lib/domain/note.ts b/src/lib/domain/note.ts new file mode 100644 index 000000000..495c58c98 --- /dev/null +++ b/src/lib/domain/note.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export interface Note { + id: string; + content: string; + x: number; + y: number; + width: number; + height: number; + color: string; + order?: number; +} + +export const noteSchema: z.ZodType = z.object({ + id: z.string(), + content: z.string(), + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + color: z.string(), + order: z.number().optional(), +}); diff --git a/src/pages/editor-page/canvas/area-node/area-node.tsx b/src/pages/editor-page/canvas/area-node/area-node.tsx index 5ace33a17..47c3b622c 100644 --- a/src/pages/editor-page/canvas/area-node/area-node.tsx +++ b/src/pages/editor-page/canvas/area-node/area-node.tsx @@ -32,7 +32,8 @@ export const AreaNode: React.FC> = React.memo( const [editMode, setEditMode] = useState(false); const [areaName, setAreaName] = useState(area.name); const inputRef = React.useRef(null); - const { openAreaFromSidebar, selectSidebarSection } = useLayout(); + const { openAreaFromSidebar, selectSidebarSection, selectVisualsTab } = + useLayout(); const focused = !!selected && !dragging; @@ -50,9 +51,15 @@ export const AreaNode: React.FC> = React.memo( }, [area.name]); const openAreaInEditor = useCallback(() => { - selectSidebarSection('areas'); + selectSidebarSection('visuals'); + selectVisualsTab('areas'); openAreaFromSidebar(area.id); - }, [selectSidebarSection, openAreaFromSidebar, area.id]); + }, [ + selectSidebarSection, + openAreaFromSidebar, + area.id, + selectVisualsTab, + ]); useClickAway(inputRef, editAreaName); useKeyPressEvent('Enter', editAreaName); diff --git a/src/pages/editor-page/canvas/canvas-context-menu.tsx b/src/pages/editor-page/canvas/canvas-context-menu.tsx index 6fcb83481..bb7c039b9 100644 --- a/src/pages/editor-page/canvas/canvas-context-menu.tsx +++ b/src/pages/editor-page/canvas/canvas-context-menu.tsx @@ -2,6 +2,7 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuTrigger, } from '@/components/context-menu/context-menu'; import { useBreakpoint } from '@/hooks/use-breakpoint'; @@ -10,7 +11,7 @@ import { useDialog } from '@/hooks/use-dialog'; import { useReactFlow } from '@xyflow/react'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Table, Workflow, Group, View } from 'lucide-react'; +import { Table, Workflow, Group, View, StickyNote } from 'lucide-react'; import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter'; import { useLocalConfig } from '@/hooks/use-local-config'; import { useCanvas } from '@/hooks/use-canvas'; @@ -19,7 +20,8 @@ import { defaultSchemas } from '@/lib/data/default-schemas'; export const CanvasContextMenu: React.FC = ({ children, }) => { - const { createTable, readonly, createArea, databaseType } = useChartDB(); + const { createTable, readonly, createArea, databaseType, createNote } = + useChartDB(); const { schemasDisplayed } = useDiagramFilter(); const { openCreateRelationshipDialog } = useDialog(); const { screenToFlowPosition } = useReactFlow(); @@ -121,6 +123,21 @@ export const CanvasContextMenu: React.FC = ({ [createArea, screenToFlowPosition] ); + const createNoteHandler = useCallback( + (event: React.MouseEvent) => { + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + createNote({ + x: position.x, + y: position.y, + }); + }, + [createNote, screenToFlowPosition] + ); + const createRelationshipHandler = useCallback(() => { openCreateRelationshipDialog(); }, [openCreateRelationshipDialog]); @@ -158,6 +175,7 @@ export const CanvasContextMenu: React.FC = ({ {t('canvas_context_menu.new_relationship')} + = ({ {t('canvas_context_menu.new_area')} + + {t('canvas_context_menu.new_note')} + + ); diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index bf0afe293..f8ebf9095 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -83,6 +83,9 @@ import { useCanvas } from '@/hooks/use-canvas'; import type { AreaNodeType } from './area-node/area-node'; import { AreaNode } from './area-node/area-node'; import type { Area } from '@/lib/domain/area'; +import type { NoteNodeType } from './note-node/note-node'; +import { NoteNode } from './note-node/note-node'; +import type { Note } from '@/lib/domain/note'; import type { TempCursorNodeType } from './temp-cursor-node/temp-cursor-node'; import { TEMP_CURSOR_HANDLE_ID, @@ -123,6 +126,7 @@ export type EdgeType = export type NodeType = | TableNodeType | AreaNodeType + | NoteNodeType | TempCursorNodeType | CreateRelationshipNodeType; @@ -137,6 +141,7 @@ const edgeTypes: EdgeTypes = { const nodeTypes: NodeTypes = { table: TableNode, area: AreaNode, + note: NoteNode, 'temp-cursor': TempCursorNode, 'create-relationship': CreateRelationshipNode, }; @@ -238,6 +243,21 @@ const areaToAreaNode = ( }; }; +const noteToNoteNode = (note: Note): NoteNodeType => { + return { + id: note.id, + type: 'note', + position: { x: note.x, y: note.y }, + data: { note }, + width: note.width, + height: note.height, + zIndex: 50, + style: { + zIndex: 50, + }, + }; +}; + export interface CanvasProps { initialTables: DBTable[]; } @@ -254,6 +274,7 @@ export const Canvas: React.FC = ({ initialTables }) => { const { tables, areas, + notes, relationships, createRelationship, createDependency, @@ -267,6 +288,8 @@ export const Canvas: React.FC = ({ initialTables }) => { readonly, removeArea, updateArea, + removeNote, + updateNote, highlightedCustomType, highlightCustomTypeId, } = useChartDB(); @@ -287,6 +310,7 @@ export const Canvas: React.FC = ({ initialTables }) => { endFloatingEdgeCreation, hoveringTableId, hideCreateRelationshipNode, + events: canvasEvents, } = useCanvas(); const { filter, loading: filterLoading } = useDiagramFilter(); const { checkIfNewTable } = useDiff(); @@ -543,6 +567,7 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, }) ), + ...notes.map((note) => noteToNoteNode(note)), ...prevNodes.filter( (n) => n.type === 'temp-cursor' || @@ -560,6 +585,7 @@ export const Canvas: React.FC = ({ initialTables }) => { }, [ tables, areas, + notes, setNodes, filter, databaseType, @@ -975,6 +1001,13 @@ export const Canvas: React.FC = ({ initialTables }) => { sizeChanges: areaSizeChanges, } = findRelevantNodesChanges(changesToApply, 'area'); + // Then, detect note changes + const { + positionChanges: notePositionChanges, + removeChanges: noteRemoveChanges, + sizeChanges: noteSizeChanges, + } = findRelevantNodesChanges(changesToApply, 'note'); + // Then, detect table changes const { positionChanges, removeChanges, sizeChanges } = findRelevantNodesChanges(changesToApply, 'table'); @@ -1144,6 +1177,49 @@ export const Canvas: React.FC = ({ initialTables }) => { } } + // Handle note changes + if ( + notePositionChanges.length > 0 || + noteRemoveChanges.length > 0 || + noteSizeChanges.length > 0 + ) { + const notesUpdates: Record> = {}; + // Handle note position changes + notePositionChanges.forEach((change) => { + if (change.type === 'position' && change.position) { + notesUpdates[change.id] = { + ...notesUpdates[change.id], + x: change.position.x, + y: change.position.y, + }; + } + }); + + // Handle note size changes + noteSizeChanges.forEach((change) => { + if (change.type === 'dimensions' && change.dimensions) { + notesUpdates[change.id] = { + ...notesUpdates[change.id], + width: change.dimensions.width, + height: change.dimensions.height, + }; + } + }); + + // Handle note removal + noteRemoveChanges.forEach((change) => { + removeNote(change.id); + delete notesUpdates[change.id]; + }); + + // Apply note updates to storage + if (Object.keys(notesUpdates).length > 0) { + for (const [id, updates] of Object.entries(notesUpdates)) { + updateNote(id, updates); + } + } + } + return onNodesChange(changesToApply); }, [ @@ -1153,6 +1229,8 @@ export const Canvas: React.FC = ({ initialTables }) => { findRelevantNodesChanges, updateArea, removeArea, + updateNote, + removeNote, readonly, tables, areas, @@ -1423,23 +1501,35 @@ export const Canvas: React.FC = ({ initialTables }) => { return [...edges, tempEdge]; }, [edges, tempFloatingEdge, cursorPosition, hoveringTableId]); - const onPaneClickHandler = useCallback(() => { - if (tempFloatingEdge) { - endFloatingEdgeCreation(); - setCursorPosition(null); - } + const onPaneClickHandler = useCallback( + (event: React.MouseEvent) => { + if (tempFloatingEdge) { + endFloatingEdgeCreation(); + setCursorPosition(null); + } - // Close CreateRelationshipNode if it exists - hideCreateRelationshipNode(); + // Close CreateRelationshipNode if it exists + hideCreateRelationshipNode(); - // Exit edit table mode - exitEditTableMode(); - }, [ - tempFloatingEdge, - exitEditTableMode, - endFloatingEdgeCreation, - hideCreateRelationshipNode, - ]); + // Exit edit table mode + exitEditTableMode(); + + canvasEvents.emit({ + action: 'pan_click', + data: { + x: event.clientX, + y: event.clientY, + }, + }); + }, + [ + canvasEvents, + tempFloatingEdge, + exitEditTableMode, + endFloatingEdgeCreation, + hideCreateRelationshipNode, + ] + ); return ( diff --git a/src/pages/editor-page/canvas/note-node/note-node.tsx b/src/pages/editor-page/canvas/note-node/note-node.tsx new file mode 100644 index 000000000..422755ea0 --- /dev/null +++ b/src/pages/editor-page/canvas/note-node/note-node.tsx @@ -0,0 +1,219 @@ +import React, { useCallback, useState, useRef } from 'react'; +import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'; +import { Pencil, Trash2 } from 'lucide-react'; +import type { Note } from '@/lib/domain/note'; +import { useChartDB } from '@/hooks/use-chartdb'; +import { useClickAway, useKeyPressEvent } from 'react-use'; +import { ColorPicker } from '@/components/color-picker/color-picker'; +import { Button } from '@/components/button/button'; +import { cn } from '@/lib/utils'; +import { useCanvas } from '@/hooks/use-canvas'; +import type { CanvasEvent } from '@/context/canvas-context/canvas-context'; +import { useTheme } from '@/hooks/use-theme'; + +export interface NoteNodeProps extends NodeProps { + data: { + note: Note; + }; +} + +export type NoteNodeType = Node<{ note: Note }, 'note'>; + +export const NoteNode: React.FC = ({ data, selected }) => { + const { note } = data; + const { updateNote, removeNote, readonly } = useChartDB(); + const [editMode, setEditMode] = useState(false); + const [content, setContent] = useState(note.content); + const textareaRef = useRef(null); + const { events } = useCanvas(); + const { effectiveTheme } = useTheme(); + + const saveContent = useCallback(() => { + if (!editMode) return; + updateNote(note.id, { content: content.trim() }); + setEditMode(false); + }, [editMode, content, note.id, updateNote]); + + const abortEdit = useCallback(() => { + setEditMode(false); + setContent(note.content); + }, [note.content]); + + const enterEditMode = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (readonly) return; + setEditMode(true); + }, + [readonly] + ); + + const handleDelete = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + removeNote(note.id); + }, + [note.id, removeNote] + ); + + const handleColorChange = useCallback( + (color: string) => { + updateNote(note.id, { color }); + }, + [note.id, updateNote] + ); + + const handleDoubleClick = useCallback< + React.MouseEventHandler + >( + (e) => { + if (!readonly) { + enterEditMode(e); + } + }, + [enterEditMode, readonly] + ); + + useClickAway(textareaRef, saveContent); + useKeyPressEvent('Escape', abortEdit); + + const eventConsumer = useCallback( + (event: CanvasEvent) => { + if (!editMode) { + return; + } + + if (event.action === 'pan_click') { + saveContent(); + } + }, + [editMode, saveContent] + ); + + events.useSubscription(eventConsumer); + + // Focus textarea when entering edit mode + React.useEffect(() => { + if (textareaRef.current && editMode) { + textareaRef.current.focus(); + } + }, [editMode]); + + const getHeaderColor = (color: string) => { + // Return the original color for header (full saturation) + return color; + }; + + const getBodyColor = (color: string) => { + const hex = color.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + const isDark = effectiveTheme === 'dark'; + + if (isDark) { + // Dark mode: darken the color by mixing with dark gray (30% original + 70% dark) + const darkR = Math.round(r * 0.3 + 0 * 0.7); + const darkG = Math.round(g * 0.3 + 0 * 0.7); + const darkB = Math.round(b * 0.3 + 0 * 0.7); + return `rgb(${darkR}, ${darkG}, ${darkB})`; + } else { + // Light mode: lighten the color by mixing with white (30% original + 70% white) + const lightR = Math.round(r * 0.3 + 255 * 0.7); + const lightG = Math.round(g * 0.3 + 255 * 0.7); + const lightB = Math.round(b * 0.3 + 255 * 0.7); + return `rgb(${lightR}, ${lightG}, ${lightB})`; + } + }; + + return ( +
+ {/* Notepad header with binding */} +
+ + + + {/* Note body */} +
+ {/* Corner fold (bottom-right) */} +
+ + {/* Content area */} + {editMode ? ( +