diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 5191cbf84..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -open_collective: drawdb -buy_me_a_coffee: drawdb \ No newline at end of file diff --git a/README.md b/README.md index 674f72e2c..13f2207e7 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,6 @@ Follow us on X - - Support us -

demo

diff --git a/package-lock.json b/package-lock.json index df8355225..37e0f4f8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,14 @@ "dependencies": { "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.6.3", + "@dbml/core": "^3.9.7-alpha.0", "@douyinfe/semi-ui": "^2.51.3", "@lexical/react": "^0.12.5", "@uiw/codemirror-theme-github": "^4.21.25", "@uiw/codemirror-theme-vscode": "^4.21.25", "@uiw/react-codemirror": "^4.21.25", "@vercel/analytics": "^1.2.2", + "@vercel/speed-insights": "^1.2.0", "axios": "^1.7.4", "classnames": "^2.5.1", "dexie": "^3.2.4", @@ -1978,6 +1980,34 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@dbml/core": { + "version": "3.9.7-alpha.0", + "resolved": "https://registry.npmjs.org/@dbml/core/-/core-3.9.7-alpha.0.tgz", + "integrity": "sha512-KGXr7p80XuoqQJumOs2+RHRBBH703gNxM0uiEvT1FF945+H4LriNK4ZgbXqe2ObmRNbwF2/TYFou+lqkh+tbUw==", + "license": "Apache-2.0", + "dependencies": { + "@dbml/parse": "^3.9.7-alpha.0", + "antlr4": "^4.13.1", + "lodash": "^4.17.15", + "parsimmon": "^1.13.0", + "pluralize": "^8.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@dbml/parse": { + "version": "3.9.7-alpha.0", + "resolved": "https://registry.npmjs.org/@dbml/parse/-/parse-3.9.7-alpha.0.tgz", + "integrity": "sha512-QT0rmbbnjn6hKbGXMhvdw62Gn8YgXjvG5a+0+9EoZFpFdl/Y8VSPlHqpHbdMas2kOpusMgpa1YRFaTMApZM7Mw==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "lodash": "^4.17.21" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", @@ -4244,6 +4274,40 @@ } } }, + "node_modules/@vercel/speed-insights": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-1.2.0.tgz", + "integrity": "sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw==", + "hasInstallScript": true, + "peerDependencies": { + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", @@ -4354,6 +4418,14 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/antlr4": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/antlr4/-/antlr4-4.13.2.tgz", + "integrity": "sha512-QiVbZhyy4xAZ17UPEuG3YTOt8ZaoeOR1CvEAqrEsDBsOqINslaB147i9xqljZqoyf5S+EUlGStaj+t22LT9MOg==", + "engines": { + "node": ">=16" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -8423,6 +8495,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parsimmon": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/parsimmon/-/parsimmon-1.18.1.tgz", + "integrity": "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw==" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8567,6 +8644,14 @@ "node": ">=8" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.4.41", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", diff --git a/package.json b/package.json index 49bbee050..a874a5343 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ "dependencies": { "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.6.3", + "@dbml/core": "^3.9.7-alpha.0", "@douyinfe/semi-ui": "^2.51.3", "@lexical/react": "^0.12.5", "@uiw/codemirror-theme-github": "^4.21.25", "@uiw/codemirror-theme-vscode": "^4.21.25", "@uiw/react-codemirror": "^4.21.25", "@vercel/analytics": "^1.2.2", + "@vercel/speed-insights": "^1.2.0", "axios": "^1.7.4", "classnames": "^2.5.1", "dexie": "^3.2.4", diff --git a/public/robots.txt b/public/robots.txt index f09f128b2..5ebe41e36 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -2,7 +2,6 @@ User-agent: * Allow: / Allow: /editor -Allow: /shortcuts Allow: /templates Disallow: /bug-report Disallow: /survey \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 65aebc600..386717109 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,7 +3,6 @@ import { useLayoutEffect } from "react"; import Editor from "./pages/Editor"; import Survey from "./pages/Survey"; import BugReport from "./pages/BugReport"; -import Shortcuts from "./pages/Shortcuts"; import Templates from "./pages/Templates"; import LandingPage from "./pages/LandingPage"; import SettingsContextProvider from "./context/SettingsContext"; @@ -33,14 +32,6 @@ export default function App() { } /> - - - - } - /> { + const handleGripField = (field, fieldTableid) => { // A field can be a foreign key only if it's a primary key or both NOT NULL and UNIQUE. // If it can't be selected, show an error message and exit. if (!field.primary && !(field.notNull && field.unique)) { @@ -646,10 +647,14 @@ export default function Canvas() { setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 }); setLinkingLine({ ...linkingLine, - startTableId: field.tableId, + startTableId: fieldTableid, startFieldId: field.id, startX: pointer.spaces.diagram.x, startY: pointer.spaces.diagram.y, + endX: pointer.spaces.diagram.x, + endY: pointer.spaces.diagram.y, + endTableId: -1, + endFieldId: -1, }); setLinking(true); }; @@ -659,10 +664,22 @@ export default function Canvas() { // Get the childTable and parentTable const childTable = tables.find((t) => t.id === hoveredTable.tableId); const parentTable = tables.find((t) => t.id === linkingLine.startTableId); + + if (!parentTable) { + console.error("Parent table not found for linking."); + setLinking(false); + return; + } + if (!childTable) { + console.error("Child table not found for linking."); + setLinking(false); + return; + } const parentFields = parentTable.fields.filter((field) => field.primary); - + if (parentFields.length === 0) { Toast.info(t("no_primary_key")); + setLinking(false); return; } // If the relationship is recursive @@ -670,6 +687,7 @@ export default function Canvas() { if (!recursiveRelation) { if (!areFieldsCompatible(parentFields, childTable)) { Toast.info(t("duplicate_field_name")); + setLinking(false); return; } } @@ -677,13 +695,21 @@ export default function Canvas() { const alreadyLinked = relationships.some( (rel) => rel.startTableId === linkingLine.startTableId && - rel.endTableId === hoveredTable.tableId + rel.endTableId === hoveredTable.tableId && + rel.startFieldId === linkingLine.startFieldId && + rel.endFieldId === (parentFields.map( + (field, index) => + childTable.fields.reduce( + (maxId, f) => + Math.max(maxId, typeof f.id === 'number' ? f.id : -1), -1) + 1 + index)[0]) ); if (alreadyLinked) { Toast.info(t("duplicate_relationship")); + setLinking(false); return; } - + // Save the ID of the child table before modifying its fields + const childTableIdForFks = childTable.id; // Generate new fields for the childTable const newFields = parentFields.map((field, index) => ({ name: recursiveRelation ? "" : field.name, @@ -701,31 +727,30 @@ export default function Canvas() { tableId: parentTable.id, fieldId: field.id, }, - id: childTable.fields.length + index, // Ensure IDs are unique + id: childTable.fields.reduce((maxId, f) => Math.max(maxId, typeof f.id === 'number' ? f.id : -1), -1) + 1 + index, })); - // Concatenate the existing fields with the new fields const updatedChildFields = [...childTable.fields, ...newFields]; - // Update the childTable with the new fields - updateTable(childTable.id, { + updateTable(childTableIdForFks, { fields: updatedChildFields, }); - + const actualStartFieldId = parentTable.fields.find( + (f) => f.id === linkingLine.startFieldId); + const relationshipName = `${parentTable.name}_${actualStartFieldId ? actualStartFieldId.name : 'table'}`; // Use the updated childTable fields to create the new relationship const newRelationship = { - ...linkingLine, - endTableId: childTable.id, - // The new fields are added at the end of the childTable fields - endFieldId: updatedChildFields.length - 1, - startTableId: parentTable.id, - startFieldId: parentFields[0].id, - Cardinality: Cardinality.ONE_TO_ONE, + startTableId: linkingLine.startTableId, + startFieldId: linkingLine.startFieldId, + endTableId: hoveredTable.tableId, + endFieldId: newFields.length > 0 ? newFields[0].id : undefined, + relationshipType: RelationshipType.ONE_TO_ONE, // Default, can be changed by editing the relationship + cardinality: RelationshipCardinalities[RelationshipType.ONE_TO_ONE][0].label, updateConstraint: Constraint.NONE, deleteConstraint: Constraint.NONE, - name: `fk_${parentTable.name}_${parentFields[0].name}`, - id: relationships.length, - endField: newFields, + name: relationshipName, + subtype: false, + subtype_restriction: "", }; delete newRelationship.startX; @@ -733,7 +758,7 @@ export default function Canvas() { delete newRelationship.endX; delete newRelationship.endY; // Add the new relationship to the relationships array - addRelationship(newRelationship); + addRelationship(newRelationship, newFields, childTableIdForFks, true); setLinking(false); }; @@ -743,7 +768,7 @@ export default function Canvas() { (e) => { e.preventDefault(); - if (e.ctrlKey) { + if (e.ctrlKey || e.metaKey) { // How "eager" the viewport is to // center the cursor's coordinates const eagernessFactor = 0.05; diff --git a/src/components/EditorCanvas/Relationship.jsx b/src/components/EditorCanvas/Relationship.jsx index fd2935435..208bfdf15 100644 --- a/src/components/EditorCanvas/Relationship.jsx +++ b/src/components/EditorCanvas/Relationship.jsx @@ -1,15 +1,33 @@ -import { useRef } from "react"; +import { useRef, useState, useEffect } from "react"; import { - Cardinality, + RelationshipType, + RelationshipCardinalities, + ParentCardinality, darkBgTheme, + Notation, ObjectType, Tab, + tableFieldHeight, + tableHeaderHeight, + tableColorStripHeight, + SubtypeRestriction, } from "../../data/constants"; import { calcPath } from "../../utils/calcPath"; import { useDiagram, useSettings, useLayout, useSelect } from "../../hooks"; import { useTranslation } from "react-i18next"; import { SideSheet } from "@douyinfe/semi-ui"; import RelationshipInfo from "../EditorSidePanel/RelationshipsTab/RelationshipInfo"; +import { + CrowParentLines, + CrowParentDiamond, + CrowsFootChild, + IDEFZM, + DefaultNotation, + subDP, + subDT, + subOP, + subOT +} from "./RelationshipFormat"; const labelFontSize = 16; @@ -25,30 +43,225 @@ export default function Relationship({ data }) { const pathRef = useRef(); const labelRef = useRef(); + const [breakpoints, setBreakpoints] = useState(data.breakpoints ?? []); + const [draggingBpIndex, setDraggingBpIndex] = useState(null); + + let subtypevar = "0"; + const getSubtypeFormat = () => { + if (!data.subtype) return null; + switch (data.subtype_restriction) { + case SubtypeRestriction.DISJOINT_TOTAL: + subtypevar = "1"; + return subDT; + case SubtypeRestriction.DISJOINT_PARTIAL: + subtypevar = "2"; + return subDP; + case SubtypeRestriction.OVERLAPPING_TOTAL: + subtypevar = "3"; + return subOT; + case SubtypeRestriction.OVERLAPPING_PARTIAL: + subtypevar = "4"; + return subOP; + default: + return null; + } + }; + const subtypeFormat = getSubtypeFormat(); + + const startTable = tables[data.startTableId]; + const endTable = tables[data.endTableId]; + + if (!startTable || !endTable) return null; + + const getSortedFields = (fields) => { + if (!fields) return []; + return [...fields].sort((a, b) => { + const aIsPK = a.primary; + const bIsPK = b.primary; + const aIsFK = a.foreignK === true; + const bIsFK = b.foreignK === true; + + let groupA = aIsPK ? 1 : !aIsFK ? 2 : 3; + let groupB = bIsPK ? 1 : !bIsFK ? 2 : 3; + + return groupA - groupB; + }); + }; + + let startFieldYOffset = 0; + let endFieldYOffset = 0; + const effectiveColorStripHeight = settings.notation === Notation.DEFAULT ? tableColorStripHeight : 0; + const totalHeaderHeightForFields = tableHeaderHeight + effectiveColorStripHeight; + + if (startTable && startTable.fields && data.startFieldId !== undefined) { + const sortedStartFields = getSortedFields(startTable.fields); + const startFieldIndex = sortedStartFields.findIndex(f => f.id === data.startFieldId); + startFieldYOffset = startFieldIndex !== -1 + ? totalHeaderHeightForFields + (startFieldIndex * tableFieldHeight) + (tableFieldHeight / 2) + : tableHeaderHeight / 2; + } + + if (endTable && endTable.fields && data.endFieldId !== undefined) { + const sortedEndFields = getSortedFields(endTable.fields); + const endFieldIndex = sortedEndFields.findIndex(f => f.id === data.endFieldId); + endFieldYOffset = endFieldIndex !== -1 + ? totalHeaderHeightForFields + (endFieldIndex * tableFieldHeight) + (tableFieldHeight / 2) + : tableHeaderHeight / 2; + } + + let determinedRelationshipType = null; + if (endTable && endTable.fields && data.endFieldId !== undefined) { + const foreignKeyField = endTable.fields.find(field => field.id === data.endFieldId); + if (foreignKeyField) { + determinedRelationshipType = foreignKeyField.primary ? "0" : "5.5"; + } + } + const relationshipType = determinedRelationshipType ?? data.lineType ?? "0"; + + const getForeignKeyFields = () => { + if (!endTable || !endTable.fields) return []; + + if (Array.isArray(data.endFieldId)) { + return endTable.fields.filter(f => data.endFieldId.includes(f.id)); + } else if (data.endFieldId !== undefined) { + return endTable.fields.filter(f => f.id === data.endFieldId); + } + return []; + }; + + let direction = 1; let cardinalityStart = "1"; let cardinalityEnd = "1"; - switch (data.cardinality) { - // the translated values are to ensure backwards compatibility - case t(Cardinality.MANY_TO_ONE): - case Cardinality.MANY_TO_ONE: - cardinalityStart = "n"; - cardinalityEnd = "1"; - break; - case t(Cardinality.ONE_TO_MANY): - case Cardinality.ONE_TO_MANY: - cardinalityStart = "1"; - cardinalityEnd = "n"; - break; - case t(Cardinality.ONE_TO_ONE): - case Cardinality.ONE_TO_ONE: - cardinalityStart = "1"; - cardinalityEnd = "1"; - break; - default: - break; + const isCrowOrIDEF = settings.notation === Notation.CROWS_FOOT || settings.notation === Notation.IDEF1X; + const isDefault = settings.notation === Notation.DEFAULT; + const fkFields = getForeignKeyFields(); + + if (isCrowOrIDEF) { + const allNullable = fkFields.length > 0 && fkFields.every(field => !field.notNull); + cardinalityStart = allNullable + ? ParentCardinality.NULLEABLE.label + : ParentCardinality.DEFAULT.label; + if (data.relationshipType === RelationshipType.ONE_TO_ONE) { + cardinalityEnd = + data.cardinality || + RelationshipCardinalities[RelationshipType.ONE_TO_ONE][0].label; + } else if (data.relationshipType === RelationshipType.ONE_TO_MANY) { + cardinalityEnd = + data.cardinality || + RelationshipCardinalities[RelationshipType.ONE_TO_MANY][0].label; + } + } else if (isDefault) { + cardinalityStart = "1"; + cardinalityEnd = data.relationshipType === RelationshipType.ONE_TO_MANY ? "n" : "1"; + } + + const formats = { + notation: { + default: { + one_to_one: DefaultNotation, + one_to_many: DefaultNotation, + }, + crows_foot: { + child: CrowsFootChild, + parent_lines: CrowParentLines, + parent_diamond: CrowParentDiamond, + }, + idef1x: { + one_to_one: IDEFZM, + one_to_many: IDEFZM, + parent_diamond: CrowParentDiamond, + }, + } + }; + + const effectiveNotationKey = Object.prototype.hasOwnProperty.call(formats.notation, settings.notation) + ? settings.notation + : Notation.DEFAULT; + + const currentNotation = formats.notation[effectiveNotationKey]; + + let parentFormat = null; + if (!data.subtype) { + if (settings.notation === Notation.CROWS_FOOT) { + if (cardinalityStart === "(1,1)") { + parentFormat = currentNotation.parent_lines; + } else if (cardinalityStart === "(0,1)") { + parentFormat = currentNotation.parent_diamond; + } + } else if (settings.notation === Notation.IDEF1X) { + if (cardinalityStart === "(0,1)") { + parentFormat = currentNotation.parent_diamond; + } + } } + let childFormat; + if (!data.subtype) { + if (settings.notation === Notation.CROWS_FOOT) { + childFormat = currentNotation.child; + } else if (settings.notation === Notation.IDEF1X) { + if (data.relationshipType === RelationshipType.ONE_TO_ONE) { + childFormat = currentNotation.one_to_one; + } else if (data.relationshipType === RelationshipType.ONE_TO_MANY) { + childFormat = currentNotation.one_to_many; + } + } else { + if (data.relationshipType === RelationshipType.ONE_TO_ONE) { + childFormat = currentNotation.one_to_one; + } else if (data.relationshipType === RelationshipType.ONE_TO_MANY) { + childFormat = currentNotation.one_to_many; + } + } + } + + const pathData = { + ...data, + startTable: { + x: startTable ? startTable.x : 0, + y: startTable ? startTable.y + startFieldYOffset : 0, + }, + endTable: { + x: endTable ? endTable.x : 0, + y: endTable ? endTable.y + endFieldYOffset : 0, + }, + breakpoints, + }; + + const { path, startAttach, endAttach, breakpoints: defaultBreakpoints } = calcPath( + pathData, + settings.tableWidth + ); + + useEffect(() => { + if (!breakpoints?.length) { + setBreakpoints(defaultBreakpoints); + } + }, [startTable, endTable]); + + const handleBpPointerDown = (e, idx) => { + e.stopPropagation(); + setDraggingBpIndex(idx); + }; + + const handlePointerMove = (e) => { + if (draggingBpIndex !== null) { + const svg = e.target.ownerSVGElement; + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const cursor = pt.matrixTransform(svg.getScreenCTM().inverse()); + + setBreakpoints((prev) => { + const updated = [...prev]; + updated[draggingBpIndex] = { x: cursor.x, y: cursor.y }; + return updated; + }); + } + }; + + const handlePointerUp = () => setDraggingBpIndex(null); + let cardinalityStartX = 0; let cardinalityEndX = 0; let cardinalityStartY = 0; @@ -60,72 +273,133 @@ export default function Relationship({ data }) { let labelHeight = labelRef.current?.getBBox().height ?? 0; const cardinalityOffset = 28; + let angle = 0; + let subtypePoint = null; - if (pathRef.current) { - const pathLength = pathRef.current.getTotalLength(); + if (pathRef.current && path) { + const pathLength = pathRef.current.getTotalLength() - cardinalityOffset; const labelPoint = pathRef.current.getPointAtLength(pathLength / 2); + subtypePoint = pathRef.current.getPointAtLength(pathLength * 0.33); + const subtypeTangentPoint = pathRef.current.getPointAtLength(pathLength * 0.33 + 1); + const dx = subtypeTangentPoint.x - subtypePoint.x; + const dy = subtypeTangentPoint.y - subtypePoint.y; + angle = (Math.atan2(dy, dx) * 180) / Math.PI; + labelX = labelPoint.x - (labelWidth ?? 0) / 2; labelY = labelPoint.y + (labelHeight ?? 0) / 2; const point1 = pathRef.current.getPointAtLength(cardinalityOffset); - cardinalityStartX = point1.x; - cardinalityStartY = point1.y; - const point2 = pathRef.current.getPointAtLength( - pathLength - cardinalityOffset, - ); - cardinalityEndX = point2.x; - cardinalityEndY = point2.y; + cardinalityStartX = startAttach.x; + cardinalityStartY = startAttach.y; + + const point2 = pathRef.current.getPointAtLength(pathLength); + cardinalityEndX = endAttach.x; + cardinalityEndY = endAttach.y; } const edit = () => { if (!layout.sidebar) { - setSelectedElement((prev) => ({ - ...prev, + setSelectedElement({ element: ObjectType.RELATIONSHIP, id: data.id, open: true, - })); + }); } else { - setSelectedElement((prev) => ({ - ...prev, + setSelectedElement({ currentTab: Tab.RELATIONSHIPS, element: ObjectType.RELATIONSHIP, id: data.id, open: true, - })); + }); if (selectedElement.currentTab !== Tab.RELATIONSHIPS) return; - document - .getElementById(`scroll_ref_${data.id}`) - .scrollIntoView({ behavior: "smooth" }); + document.getElementById(`scroll_ref_${data.id}`)?.scrollIntoView({ behavior: "smooth" }); } }; + if ((settings.notation === Notation.CROWS_FOOT || settings.notation === Notation.IDEF1X) && cardinalityEndX < cardinalityStartX) { + direction = -1; + } + return ( <> - + + + + {(breakpoints ?? []).map((bp, idx) => ( + handleBpPointerDown(e, idx)} + /> + ))} + + {parentFormat && parentFormat( + cardinalityStartX, + cardinalityStartY, + direction, + )} + + {settings.notation === 'default' && settings.showCardinality && childFormat && childFormat( + pathRef, + cardinalityEndX, + cardinalityEndY, + cardinalityStartX, + cardinalityStartY, + direction, + cardinalityStart, + cardinalityEnd + )} + + {settings.notation !== 'default' && childFormat && childFormat( + pathRef, + cardinalityEndX, + cardinalityEndY, + cardinalityStartX, + cardinalityStartY, + direction, + cardinalityStart, + cardinalityEnd, + settings.showCardinality + )} + + {subtypeFormat && subtypeFormat( + subtypePoint, + angle, + settings.notation, + subtypevar, + direction, + cardinalityStart, + cardinalityEnd + )} + {settings.showRelationshipLabels && ( <> )} - {pathRef.current && settings.showCardinality && ( - <> - - - {cardinalityStart} - - - - {cardinalityEnd} - - - )} + { + onCancel={() => setSelectedElement((prev) => ({ ...prev, open: false, - })); - }} + })) + } style={{ paddingBottom: "16px" }} >
diff --git a/src/components/EditorCanvas/RelationshipFormat.jsx b/src/components/EditorCanvas/RelationshipFormat.jsx new file mode 100644 index 000000000..64c99a921 --- /dev/null +++ b/src/components/EditorCanvas/RelationshipFormat.jsx @@ -0,0 +1,262 @@ +export function CrowParentLines(cardinalityStartX, cardinalityStartY, direction) { + return ( + <> + + + + ); +} + +export function CrowParentDiamond(cardinalityStartX, cardinalityStartY, direction) { + return ( + + ); +} + +export function CrowsFootChild( + pathRef, + cardinalityEndX, + cardinalityEndY, + cardinalityStartX, + cardinalityStartY, + direction, + cardinalityStart, + cardinalityEnd, + showCardinality +) { + const isMandatory = cardinalityEnd.startsWith("(1"); + const isOptional = cardinalityEnd.startsWith("(0"); + const isMany = cardinalityEnd.endsWith("*)"); + const isOne = cardinalityEnd.endsWith("1)"); + return ( + pathRef && ( + <> + {isMany && ( + <> + + + + + + )} + {isOne && ( + + )} + {isOptional && ( + + )} + {isMandatory && ( + + )} + {showCardinality && ( + <> + {cardinalityStart} + {cardinalityEnd} + + )} + + ) + ); +} + +export function DefaultNotation( + pathRef, + cardinalityEndX, + cardinalityEndY, + cardinalityStartX, + cardinalityStartY, + direction, + cardinalityStart, + cardinalityEnd +) { + return ( + pathRef && ( + <> + + {cardinalityStart} + + {cardinalityEnd} + + ) + ); +} + +export function IDEFZM( + pathRef, + cardinalityEndX, + cardinalityEndY, + cardinalityStartX, + cardinalityStartY, + direction, + cardinalityStart, + cardinalityEnd, + showCardinality +) { + let letter = null; + switch (cardinalityEnd) { + case "(1,*)": + letter = "P"; + break; + case "(1,1)": + letter = "L"; + break; + case "(0,1)": + letter = "Z"; + break; + } + + return ( + pathRef && ( + <> + + {letter && ( + {letter} + )} + {showCardinality && ( + <> + {cardinalityStart} + {cardinalityEnd} + + )} + + ) + ); +} + +export function subDT(point, angle, notation, subtypevar, direction, cardinalityStart, cardinalityEnd, onConnectSubtypePoint, relationshipId) { + return ( + point && subtypevar === "1" && ( + + + D + + + onConnectSubtypePoint?.(e, point.x, point.y + 20, relationshipId)} + /> + + ) + ); +} + +export function subDP(point, angle, notation, subtypevar, direction, cardinalityStart, cardinalityEnd, onConnectSubtypePoint, relationshipId) { + return ( + point && subtypevar === "2" && ( + + + D + + onConnectSubtypePoint?.(e, point.x, point.y + 20, relationshipId)} + /> + + ) + ); +} + +export function subOT(point, angle, notation, subtypevar, direction, cardinalityStart, cardinalityEnd, onConnectSubtypePoint, relationshipId) { + return ( + point && subtypevar === "3" && ( + + + O + + + onConnectSubtypePoint?.(e, point.x, point.y + 20, relationshipId)} + /> + + ) + ); +} + +export function subOP(point, angle, notation, subtypevar, direction, cardinalityStart, cardinalityEnd, onConnectSubtypePoint, relationshipId) { + return ( + point && subtypevar === "4" && ( + + + O + + onConnectSubtypePoint?.(e, point.x, point.y + 20, relationshipId)} + /> + + ) + ); +} \ No newline at end of file diff --git a/src/components/EditorCanvas/Table.jsx b/src/components/EditorCanvas/Table.jsx index 4509de91d..20132c294 100644 --- a/src/components/EditorCanvas/Table.jsx +++ b/src/components/EditorCanvas/Table.jsx @@ -5,6 +5,7 @@ import { tableFieldHeight, tableHeaderHeight, tableColorStripHeight, + Notation, } from "../../data/constants"; import { IconEdit, @@ -15,7 +16,7 @@ import { import { Popover, Tag, Button, SideSheet } from "@douyinfe/semi-ui"; import { useLayout, useSettings, useDiagram, useSelect } from "../../hooks"; import TableInfo from "../EditorSidePanel/TablesTab/TableInfo"; -import { useTranslation } from "react-i18next"; +import { useTranslation} from "react-i18next"; import { dbToTypes } from "../../data/datatypes"; import { isRtl } from "../../i18n/utils/rtl"; import i18n from "../../i18n/i18n"; @@ -61,6 +62,37 @@ export default function Table(props) { .scrollIntoView({ behavior: "smooth" }); } }; + const primaryKeyCount = tableData.fields.filter(field => field.primary).length; + + const sortedFields = [...tableData.fields].sort((a, b) => { + const aIsPK = a.primary; + const bIsPK = b.primary; + const aIsFK = a.foreignK === true; + const bIsFK = b.foreignK === true; + + let groupA; + if (aIsPK) { + groupA = 1; + } else if (!aIsFK) { + groupA = 2; + } else { + groupA = 3; + } + + let groupB; + if (bIsPK) { + groupB = 1; + } else if (!bIsFK) { + groupB = 2; + } else { + groupB = 3; + } + + if (groupA !== groupB) { + return groupA - groupB; + } + return 0; + }); return ( <> @@ -70,36 +102,49 @@ export default function Table(props) { y={tableData.y} width={settings.tableWidth} height={height} - className="group drop-shadow-lg rounded-md cursor-move" + className="group drop-shadow-lg cursor-move" onPointerDown={onPointerDown} >
-
+
{tableData.name}
@@ -189,7 +234,7 @@ export default function Table(props) {
- {tableData.fields.map((e, i) => { + {sortedFields.map((e, i) => { return settings.showFieldSummary ? ( { if (!e.isPrimary) return; @@ -315,29 +403,28 @@ export default function Table(props) { } flex items-center gap-2 overflow-hidden`} >
diff --git a/src/components/EditorHeader/ControlPanel.jsx b/src/components/EditorHeader/ControlPanel.jsx index 8a90fdce1..bd966f352 100644 --- a/src/components/EditorHeader/ControlPanel.jsx +++ b/src/components/EditorHeader/ControlPanel.jsx @@ -24,7 +24,6 @@ import { Popconfirm, } from "@douyinfe/semi-ui"; import { toPng, toJpeg, toSvg } from "html-to-image"; -import { saveAs } from "file-saver"; import { jsonToMySQL, jsonToPostgreSQL, @@ -41,6 +40,8 @@ import { MODAL, SIDESHEET, DB, + IMPORT_FROM, + Notation, } from "../../data/constants"; import jsPDF from "jspdf"; import { useHotkeys } from "react-hotkeys-hook"; @@ -74,6 +75,8 @@ import { jsonToMermaid } from "../../utils/exportAs/mermaid"; import { isRtl } from "../../i18n/utils/rtl"; import { jsonToDocumentation } from "../../utils/exportAs/documentation"; import { IdContext } from "../Workspace"; +import { socials } from "../../data/socials"; +import { toDBML } from "../../utils/exportAs/dbml"; export default function ControlPanel({ diagramId, @@ -91,6 +94,7 @@ export default function ControlPanel({ filename: `${title}_${new Date().toISOString()}`, extension: "", }); + const [importFrom, setImportFrom] = useState(IMPORT_FROM.JSON); const { saveState, setSaveState } = useSaveState(); const { layout, setLayout } = useLayout(); const { settings, setSettings } = useSettings(); @@ -106,6 +110,7 @@ export default function ControlPanel({ setRelationships, addRelationship, deleteRelationship, + restoreFieldsToTable, updateRelationship, database, } = useDiagram(); @@ -127,383 +132,534 @@ export default function ControlPanel({ if (undoStack.length === 0) return; const a = undoStack[undoStack.length - 1]; setUndoStack((prev) => prev.filter((_, i) => i !== prev.length - 1)); + + let actionForRedoStack = { ...a }; // Cloning the action for redo stack + if (a.action === Action.ADD) { if (a.element === ObjectType.TABLE) { - deleteTable(tables[tables.length - 1].id, false); + const tableIdToDelete = a.data && typeof a.data.id !== 'undefined' ? a.data.id : (tables.length > 0 ? tables[tables.length - 1].id : null); + if (tableIdToDelete !== null) { + deleteTable(tableIdToDelete, false); + } } else if (a.element === ObjectType.AREA) { - deleteArea(areas[areas.length - 1].id, false); + const areaIdToDelete = a.data && typeof a.data.id !== 'undefined' ? a.data.id : (areas.length > 0 ? areas[areas.length - 1].id : null); + if (areaIdToDelete !== null) { + deleteArea(areaIdToDelete, false); + } } else if (a.element === ObjectType.NOTE) { - deleteNote(notes[notes.length - 1].id, false); + const noteIdToDelete = a.data && typeof a.data.id !== 'undefined' ? a.data.id : (notes.length > 0 ? notes[notes.length - 1].id : null); + if (noteIdToDelete !== null) { + deleteNote(noteIdToDelete, false); + } } else if (a.element === ObjectType.RELATIONSHIP) { - deleteRelationship(a.data.id, false); + const { relationship: relToDelete, autoGeneratedFkFields, childTableIdWithGeneratedFks } = a.data; + if (relToDelete && typeof relToDelete.id !== 'undefined') { + deleteRelationship(relToDelete.id, false); + if (autoGeneratedFkFields && autoGeneratedFkFields.length > 0 && childTableIdWithGeneratedFks !== undefined) { + const childTable = tables.find(t => t.id === childTableIdWithGeneratedFks); + if (childTable) { + const newFields = childTable.fields.filter( + cf => !autoGeneratedFkFields.some(afk => afk.id === cf.id && afk.name === cf.name) + ).map((f,i) => ({...f, id:i})); + updateTable(childTableIdWithGeneratedFks, { fields: newFields }); + } + } + } } else if (a.element === ObjectType.TYPE) { - deleteType(types.length - 1, false); + const typeIdToDelete = a.data && typeof a.data.id !== 'undefined' ? a.data.id : (types.length > 0 ? types.length - 1 : null); + if (typeIdToDelete !== null) { + deleteType(typeIdToDelete, false); + } } else if (a.element === ObjectType.ENUM) { - deleteEnum(enums.length - 1, false); + const enumIdToDelete = a.data && typeof a.data.id !== 'undefined' ? a.data.id : (enums.length > 0 ? enums.length - 1 : null); + if (enumIdToDelete !== null) { + deleteEnum(enumIdToDelete, false); + } } - setRedoStack((prev) => [...prev, a]); - } else if (a.action === Action.MOVE) { - // Movimientos múltiples - if (Array.isArray(a.id)) { - setRedoStack((prev) => [ - ...prev, - { - ...a, - finalPositions: a.id.reduce((acc, id) => { - if (a.element === ObjectType.TABLE) { - acc[id] = { x: tables[id].x, y: tables[id].y }; - } else if (a.element === ObjectType.AREA) { - acc[id] = { x: areas[id].x, y: areas[id].y }; - } else if (a.element === ObjectType.NOTE) { - acc[id] = { x: notes[id].x, y: notes[id].y }; - } - return acc; - }, {}), - }, - ]); - // Revertir cada objeto a su posición inicial - a.id.forEach((id) => { - if (a.element === ObjectType.TABLE) { - updateTable(id, a.initialPositions[id]); - } else if (a.element === ObjectType.AREA) { - updateArea(id, a.initialPositions[id]); - } else if (a.element === ObjectType.NOTE) { - updateNote(id, a.initialPositions[id]); - } - }); - } else { - // Caso individual: se utiliza el campo "from" - setRedoStack((prev) => [ - ...prev, - { ...a, to: { x: tables[a.id].x, y: tables[a.id].y } }, - ]); - if (a.element === ObjectType.TABLE) { - updateTable(a.id, a.from); - } else if (a.element === ObjectType.AREA) { - updateArea(a.id, a.from); - } else if (a.element === ObjectType.NOTE) { - updateNote(a.id, a.from); + actionForRedoStack = { ...a }; // For ADD, the redo is the same action. + setRedoStack((prev) => [...prev, actionForRedoStack]); + } else if (a.action === Action.MOVE) { + let originalPositions = {}; + if (Array.isArray(a.id)) { // Multiple moves + originalPositions = a.id.reduce((acc, id) => { + let elementArr; + if (a.element === ObjectType.TABLE) elementArr = tables; + else if (a.element === ObjectType.AREA) elementArr = areas; + else if (a.element === ObjectType.NOTE) elementArr = notes; + else return acc; + + const item = elementArr.find(el => el.id === id); + if (item) { + acc[id] = { x: item.x, y: item.y }; // Save current position for redo } + return acc; + }, {}); + // Undo the movement by restoring original positions + a.originalPositions.forEach(op => { + if (a.element === ObjectType.TABLE) updateTable(op.id, { x: op.x, y: op.y }); + else if (a.element === ObjectType.AREA) updateArea(op.id, { x: op.x, y: op.y }); + else if (a.element === ObjectType.NOTE) updateNote(op.id, { x: op.x, y: op.y }); + }); + actionForRedoStack = { ...a, newPositions: originalPositions }; // For redo, we need the positions to which it was moved + } else { // Individual move + let currentItem; + if (a.element === ObjectType.TABLE) currentItem = tables.find(t => t.id === a.id); + else if (a.element === ObjectType.AREA) currentItem = areas.find(ar => ar.id === a.id); + else if (a.element === ObjectType.NOTE) currentItem = notes.find(n => n.id === a.id); + + if (currentItem) { + actionForRedoStack = { ...a, to: { x: currentItem.x, y: currentItem.y } }; // Save current position for redo } - } else if (a.action === Action.DELETE) { + if (a.element === ObjectType.TABLE) updateTable(a.id, { x: a.from.x, y: a.from.y }); + else if (a.element === ObjectType.AREA) updateArea(a.id, { x: a.from.x, y: a.from.y }); + else if (a.element === ObjectType.NOTE) updateNote(a.id, { x: a.from.x, y: a.from.y }); + } + setRedoStack((prev) => [...prev, actionForRedoStack]); + } else if (a.action === Action.DELETE) { if (a.element === ObjectType.TABLE) { - a.data.relationship.forEach((x) => addRelationship(x, false)); - addTable(a.data.table, false); + if (a.data && a.data.table) { + addTable(a.data.table, false); + } + if (a.data && a.data.relationship && Array.isArray(a.data.relationship)) { + a.data.relationship.forEach((x) => addRelationship(x, null, null, false)); + } } else if (a.element === ObjectType.RELATIONSHIP) { - addRelationship(a.data, false); + // --- Undo the deletion of a relationship --- + const { + relationship: relationshipToRestore, + childTableFieldsBeforeFkDeletion, // Use the full state of the fields + childTableIdWithPotentiallyModifiedFields + } = a.data; + + if (relationshipToRestore) { + // 1. Restore the full state of the child table's fields. + if (childTableFieldsBeforeFkDeletion && typeof childTableIdWithPotentiallyModifiedFields !== 'undefined') { + // Directly update the table with its previous fields. + updateTable(childTableIdWithPotentiallyModifiedFields, { fields: JSON.parse(JSON.stringify(childTableFieldsBeforeFkDeletion)) }); + } + // 2. Re-add the relationship object to the relationships array. + addRelationship(relationshipToRestore, null, null, false); + } } else if (a.element === ObjectType.NOTE) { - addNote(a.data, false); + if (a.data) { + addNote(a.data, false); + } } else if (a.element === ObjectType.AREA) { - addArea(a.data, false); + if (a.data) { + addArea(a.data, false); + } } else if (a.element === ObjectType.TYPE) { - addType({ id: a.id, ...a.data }, false); + if (a.data) { + addType({ id: a.id, ...a.data }, false); + } } else if (a.element === ObjectType.ENUM) { - addEnum({ id: a.id, ...a.data }, false); + if (a.data) { + addEnum({ id: a.id, ...a.data }, false); + } } - setRedoStack((prev) => [...prev, a]); + actionForRedoStack = { ...a }; + setRedoStack((prev) => [...prev, actionForRedoStack]); } else if (a.action === Action.EDIT) { + let redoStateProperties = {}; + if (a.element === ObjectType.AREA) { + const currentArea = areas.find(ar => ar.id === a.aid); + if (currentArea) redoStateProperties = { redo: { ...currentArea } }; updateArea(a.aid, a.undo); } else if (a.element === ObjectType.NOTE) { + const currentNote = notes.find(n => n.id === a.nid); + if (currentNote) redoStateProperties = { redo: { ...currentNote } }; updateNote(a.nid, a.undo); } else if (a.element === ObjectType.TABLE) { - if (a.component === "field") { - updateField(a.tid, a.fid, a.undo); - } else if (a.component === "field_delete") { - // Restores relationships - setRelationships((prev) => { - let temp = [...prev]; - a.data.relationship.forEach((r) => { - temp.splice(r.id, 0, r); - }); - temp = temp.map((e, i) => ({ ...e, id: i })); - return temp; - }); - // Restores the fields of the parent table - setTables((prev) => - prev.map((t) => - t.id === a.tid ? { ...t, fields: a.data.previousFields } : t - ) - ); - // Restores the affected child tables according to the snapshot - if (a.data.childFieldsSnapshot) { - setTables((prev) => - prev.map((t) => { - if (a.data.childFieldsSnapshot[t.id]) { - return { ...t, fields: a.data.childFieldsSnapshot[t.id] }; - } - return t; - }) - ); - } - }else if (a.component === "field_add") { - updateTable(a.tid, { - fields: tables[a.tid].fields - .filter((e) => e.id !== tables[a.tid].fields.length - 1) - .map((t, i) => ({ ...t, id: i })), - }); + const currentTable = tables.find(t => t.id === a.tid); + if (a.component === "field_update") { + if (currentTable && a.data && a.data.previousFields) { + actionForRedoStack.data = { + ...a.data, + fieldsBeforeUndoWasApplied: JSON.parse(JSON.stringify(currentTable.fields)), + }; + setTables(prevTables => + prevTables.map(table => { + if (table.id === a.tid) { + return { ...table, fields: a.data.previousFields }; + } + return table; + }) + ); + } + } else if (a.component === "field") { + let currentFieldStateForRedo = a.redo; + if (currentTable) { + const currentField = currentTable.fields.find(f => f.id === a.fid); + if (currentField) currentFieldStateForRedo = { ...currentField }; + } + redoStateProperties = { redo: currentFieldStateForRedo }; + if (typeof a.undo !== 'undefined') { + updateField(a.tid, a.fid, a.undo, false); + } + } else if (a.component === "field_delete") { + // a.data content: + // - field: the field that was deleted. + // - previousFields: the array of fields in the table BEFORE the deletion of the 'field'. + // - deletedRelationships: array of relationships that were deleted. + // - modifiedRelationshipsOriginalState: array of relationships (original state) that were modified (not deleted). + // - childFieldsSnapshot: object { tableId: arrayOfFieldsBeforeFkDeletion } + // - tid: the id of the table. + if (a.data && a.data.previousFields && a.data.field) { + actionForRedoStack.data = JSON.parse(JSON.stringify(a.data)); + // Restore the fields of the main table to their previous state. + setTables(prevTables => + prevTables.map(table => { + if (table.id === a.tid) { + return { ...table, fields: JSON.parse(JSON.stringify(a.data.previousFields)) }; + } + return table; + }) + ); + // Restore the fields in the child tables (if snapshot was saved and restoreFieldsToTable is available). + if (a.data.childFieldsSnapshot && typeof restoreFieldsToTable === 'function') { + Object.entries(a.data.childFieldsSnapshot).forEach(([childTableId, fieldsSnapshot]) => { + const numericChildTableId = parseInt(childTableId, 10); + if (!isNaN(numericChildTableId)) { + restoreFieldsToTable(numericChildTableId, JSON.parse(JSON.stringify(fieldsSnapshot))); + } + }); + } + // Restore the relationships that were deleted. + if (a.data.deletedRelationships && Array.isArray(a.data.deletedRelationships)) { + a.data.deletedRelationships.forEach(rel => { + addRelationship(JSON.parse(JSON.stringify(rel)), null, null, false); + }); + } + // Restore the relationships that were modified (their fieldId) to their original state. + if (a.data.modifiedRelationshipsOriginalState && Array.isArray(a.data.modifiedRelationshipsOriginalState)) { + a.data.modifiedRelationshipsOriginalState.forEach(originalRel => { + updateRelationship(originalRel.id, JSON.parse(JSON.stringify(originalRel)), false); + }); + } + } + } else if (a.component === "field_add") { + if (currentTable) { + const fieldAdded = a.data && a.data.fieldIdAdded; + const fieldsBeforeAdd = a.data && a.data.fieldsBeforeAdd; + + if (fieldsBeforeAdd) { + actionForRedoStack.data = { + ...a.data, + fieldThatWasAdded: currentTable.fields.find(f => f.id === fieldAdded) + }; + updateTable(a.tid, { fields: JSON.parse(JSON.stringify(fieldsBeforeAdd)) }); + } else if (currentTable.fields.length > 0) { + actionForRedoStack.data = { + ...a.data, + fieldToAdd: JSON.parse(JSON.stringify(currentTable.fields[currentTable.fields.length - 1])) + }; + updateTable(a.tid, { fields: currentTable.fields.slice(0, -1).map((f,i)=> ({...f, id:i})) }); + } + } } else if (a.component === "index_add") { - updateTable(a.tid, { - indices: tables[a.tid].indices - .filter((e) => e.id !== tables[a.tid].indices.length - 1) - .map((t, i) => ({ ...t, id: i })), - }); + if (currentTable && currentTable.indices.length > 0) { + actionForRedoStack.data = { ...a.data, indexToAdd: JSON.parse(JSON.stringify(currentTable.indices[currentTable.indices.length - 1])) }; + updateTable(a.tid, { indices: currentTable.indices.slice(0, -1).map((idx,i)=> ({...idx, id:i})) }); + } } else if (a.component === "index") { - updateTable(a.tid, { - indices: tables[a.tid].indices.map((index) => - index.id === a.iid - ? { - ...index, - ...a.undo, - } - : index, - ), - }); + if (currentTable) { + const currentIndex = currentTable.indices.find(idx => idx.id === a.iid); + if (currentIndex) redoStateProperties = { redo: { ...currentIndex } }; + updateTable(a.tid, { indices: currentTable.indices.map(idx => idx.id === a.iid ? {...idx, ...a.undo} : idx ) }); + } } else if (a.component === "index_delete") { - setTables((prev) => - prev.map((table) => { - if (table.id === a.tid) { - const temp = table.indices.slice(); - temp.splice(a.data.id, 0, a.data); - return { - ...table, - indices: temp.map((t, i) => ({ ...t, id: i })), - }; - } - return table; - }), - ); + if (a.data && currentTable) { + actionForRedoStack.data = { ...a.data, indexIdToDelete: a.data.id, tid: a.tid }; + const newIndices = [...currentTable.indices]; + newIndices.splice(a.data.id, 0, JSON.parse(JSON.stringify(a.data))); + updateTable(a.tid, { indices: newIndices.map((idx,i)=> ({...idx, id:i})) }); + } } else if (a.component === "self") { + if (currentTable) redoStateProperties = { redo: { ...currentTable, ...a.redo } }; updateTable(a.tid, a.undo); } } else if (a.element === ObjectType.RELATIONSHIP) { + const currentRel = relationships.find(r => r.id === a.rid); + if (currentRel) redoStateProperties = { redo: { ...currentRel, ...a.redo } }; updateRelationship(a.rid, a.undo); } else if (a.element === ObjectType.TYPE) { + const currentType = types.find(ty => ty.id === a.tid); if (a.component === "field_add") { - updateType(a.tid, { - fields: types[a.tid].fields.filter( - (_, i) => i !== types[a.tid].fields.length - 1, - ), - }); - } - if (a.component === "field") { - updateType(a.tid, { - fields: types[a.tid].fields.map((e, i) => - i === a.fid ? { ...e, ...a.undo } : e, - ), - }); + if (currentType) { + actionForRedoStack.data = { ...a.data, fieldToAdd: JSON.parse(JSON.stringify(currentType.fields[currentType.fields.length - 1])) }; + updateType(a.tid, { + fields: currentType.fields.slice(0, -1).map((f,i)=> ({...f, id:i})), + }); + } + } else if (a.component === "field") { + if (currentType) { + const currentField = currentType.fields.find(f => f.id === a.fid); + if (currentField) redoStateProperties = { redo: { ...currentField } }; + updateType(a.tid, { + fields: currentType.fields.map(f => + f.id === a.fid ? { ...f, ...a.undo } : f, + ), + }); + } } else if (a.component === "field_delete") { - setTypes((prev) => - prev.map((t, i) => { - if (i === a.tid) { - const temp = t.fields.slice(); - temp.splice(a.fid, 0, a.data); - return { ...t, fields: temp }; - } - return t; - }), - ); + if (a.data && currentType) { + actionForRedoStack.data = { ...a.data, fieldIdToDelete: a.data.id, typeId: a.tid }; + const newFields = [...currentType.fields]; + // Find the index of the field to delete + const originalIndex = currentType.fields.findIndex(f => f.id === a.data.id); + newFields.splice(originalIndex !== -1 ? originalIndex : newFields.length, 0, JSON.parse(JSON.stringify(a.data))); + updateType(a.tid, { fields: newFields.map((f,i)=> ({...f, id:i})) }); + } } else if (a.component === "self") { + if (currentType) redoStateProperties = { redo: { ...currentType, ...a.redo } }; updateType(a.tid, a.undo); - if (a.updatedFields) { - if (a.undo.name) { - a.updatedFields.forEach((x) => - updateField(x.tid, x.fid, { type: a.undo.name.toUpperCase() }), - ); - } + if (a.updatedFields && a.undo.name) { + a.updatedFields.forEach((x) => + updateField(x.tid, x.fid, { type: x.originalType }), + ); } } } else if (a.element === ObjectType.ENUM) { + const currentEnum = enums.find(en => en.id === a.id); + if (currentEnum) redoStateProperties = { redo: { ...currentEnum, ...a.redo } }; updateEnum(a.id, a.undo); - if (a.updatedFields) { - if (a.undo.name) { - a.updatedFields.forEach((x) => - updateField(x.tid, x.fid, { type: a.undo.name.toUpperCase() }), + if (a.updatedFields && a.undo.name) { + a.updatedFields.forEach((x) => + updateField(x.tid, x.fid, { type: x.originalType }), ); - } } } - setRedoStack((prev) => [...prev, a]); + if (Object.keys(redoStateProperties).length > 0) { + actionForRedoStack = { ...actionForRedoStack, ...redoStateProperties }; + } + setRedoStack((prev) => [...prev, actionForRedoStack]); } else if (a.action === Action.PAN) { - setTransform((prev) => ({ - ...prev, + actionForRedoStack = { ...a, redo: { ...transform.pan } }; + setTransform((prevTransform) => ({ + ...prevTransform, pan: a.undo, })); - setRedoStack((prev) => [...prev, a]); + setRedoStack((prev) => [...prev, actionForRedoStack]); } }; const redo = () => { if (redoStack.length === 0) return; const a = redoStack[redoStack.length - 1]; - setRedoStack((prev) => prev.filter((e, i) => i !== prev.length - 1)); + setRedoStack((prev) => prev.filter((_, i) => i !== prev.length - 1)); + + let actionForUndoStack = { ...a }; + if (a.action === Action.ADD) { if (a.element === ObjectType.TABLE) { - addTable(null, false); + addTable(a.data ? a.data.table || a.data : null, false); } else if (a.element === ObjectType.AREA) { - addArea(null, false); + addArea(a.data || null, false); } else if (a.element === ObjectType.NOTE) { - addNote(null, false); + addNote(a.data || null, false); } else if (a.element === ObjectType.RELATIONSHIP) { - addRelationship(a.data, false); + const { relationship, autoGeneratedFkFields, childTableIdWithGeneratedFks } = a.data; + addRelationship(relationship, autoGeneratedFkFields, childTableIdWithGeneratedFks, false); } else if (a.element === ObjectType.TYPE) { - addType(null, false); + addType(a.data ? {id: a.id, ...a.data} : null, false); } else if (a.element === ObjectType.ENUM) { - addEnum(null, false); + addEnum(a.data ? {id: a.id, ...a.data} : null, false); } - setUndoStack((prev) => [...prev, a]); + setUndoStack((prev) => [...prev, actionForUndoStack]); } else if (a.action === Action.MOVE) { - if (a.element === ObjectType.TABLE) { - setUndoStack((prev) => [ - ...prev, - { ...a, x: tables[a.id].x, y: tables[a.id].y }, - ]); - updateTable(a.id, { x: a.x, y: a.y }); - } else if (a.element === ObjectType.AREA) { - setUndoStack((prev) => [ - ...prev, - { ...a, x: areas[a.id].x, y: areas[a.id].y }, - ]); - updateArea(a.id, { x: a.x, y: a.y }); - } else if (a.element === ObjectType.NOTE) { - setUndoStack((prev) => [ - ...prev, - { ...a, x: notes[a.id].x, y: notes[a.id].y }, - ]); - updateNote(a.id, { x: a.x, y: a.y }); + if (!Array.isArray(a.id)) { + let itemBeforeMove; + if (a.element === ObjectType.TABLE) itemBeforeMove = tables.find(t => t.id === a.id); + else if (a.element === ObjectType.AREA) itemBeforeMove = areas.find(ar => ar.id === a.id); + else if (a.element === ObjectType.NOTE) itemBeforeMove = notes.find(n => n.id === a.id); + + if (itemBeforeMove) { + actionForUndoStack.from = { x: itemBeforeMove.x, y: itemBeforeMove.y }; // Current pos becomes 'from' for next undo + } + // Apply the redo move (a.to contains the target position) + if (a.element === ObjectType.TABLE) updateTable(a.id, { x: a.to.x, y: a.to.y }); + else if (a.element === ObjectType.AREA) updateArea(a.id, { x: a.to.x, y: a.to.y }); + else if (a.element === ObjectType.NOTE) updateNote(a.id, { x: a.to.x, y: a.to.y }); + } else { // Multiple moves + const currentPositions = a.id.reduce((acc, id) => { + let elementArr; + if (a.element === ObjectType.TABLE) elementArr = tables; + else if (a.element === ObjectType.AREA) elementArr = areas; + else if (a.element === ObjectType.NOTE) elementArr = notes; + else return acc; + const item = elementArr.find(el => el.id === id); + if (item) acc[id] = { x: item.x, y: item.y }; + return acc; + }, {}); + actionForUndoStack.originalPositions = Object.values(currentPositions).map((pos, index) => ({ id: a.id[index], ...pos })); + + a.newPositions.forEach(np => { // a.newPositions has target positions for redo + if (a.element === ObjectType.TABLE) updateTable(np.id, { x: np.x, y: np.y }); + else if (a.element === ObjectType.AREA) updateArea(np.id, { x: np.x, y: np.y }); + else if (a.element === ObjectType.NOTE) updateNote(np.id, { x: np.x, y: np.y }); + }); } + setUndoStack((prev) => [...prev, actionForUndoStack]); } else if (a.action === Action.DELETE) { if (a.element === ObjectType.TABLE) { - deleteTable(a.data.table.id, false); + // a.data.table should be the table object that was deleted + if (a.data && a.data.table) deleteTable(a.data.table.id, false); } else if (a.element === ObjectType.RELATIONSHIP) { - deleteRelationship(a.data.id, false); + // a.data.relationship is the relationship object that was deleted + if (a.data && a.data.relationship) deleteRelationship(a.data.relationship.id, false); } else if (a.element === ObjectType.NOTE) { - deleteNote(a.data.id, false); + if (a.data) deleteNote(a.data.id, false); } else if (a.element === ObjectType.AREA) { - deleteArea(a.data.id, false); + if (a.data) deleteArea(a.data.id, false); } else if (a.element === ObjectType.TYPE) { - deleteType(a.id, false); + if (a.data) deleteType(a.id, false); } else if (a.element === ObjectType.ENUM) { - deleteEnum(a.id, false); + if (a.data) deleteEnum(a.id, false); } - setUndoStack((prev) => [...prev, a]); + setUndoStack((prev) => [...prev, actionForUndoStack]); } else if (a.action === Action.EDIT) { + let undoStateProperties = {}; + if (a.element === ObjectType.AREA) { + const areaBeforeRedo = areas.find(ar => ar.id === a.aid); + if (areaBeforeRedo) undoStateProperties = { undo: { ...areaBeforeRedo } }; + else if (a.undo) undoStateProperties = { undo: a.undo }; updateArea(a.aid, a.redo); } else if (a.element === ObjectType.NOTE) { + const noteBeforeRedo = notes.find(n => n.id === a.nid); + if (noteBeforeRedo) undoStateProperties = { undo: { ...noteBeforeRedo } }; + else if (a.undo) undoStateProperties = { undo: a.undo }; updateNote(a.nid, a.redo); } else if (a.element === ObjectType.TABLE) { - if (a.component === "field") { - updateField(a.tid, a.fid, a.redo); + const tableBeforeRedo = tables.find(t => t.id === a.tid); + if (a.component === "field_update") { + if (tableBeforeRedo && a.data && typeof a.data.updatedFieldId !== 'undefined' && a.data.appliedValues) { + actionForUndoStack.data = { + ...a.data, + previousFields: JSON.parse(JSON.stringify(tableBeforeRedo.fields)), + }; + updateField(a.tid, a.data.updatedFieldId, a.data.appliedValues, false); + } + } else if (a.component === "field") { + let previousFieldStateForUndo = a.undo; + if (tableBeforeRedo) { + const fieldBeforeRedo = tableBeforeRedo.fields.find(f => f.id === a.fid); + if (fieldBeforeRedo) previousFieldStateForUndo = { ...fieldBeforeRedo }; + } + undoStateProperties = { undo: previousFieldStateForUndo }; + if (typeof a.redo !== 'undefined') { + updateField(a.tid, a.fid, a.redo, false); + } } else if (a.component === "field_delete") { - deleteField(a.data.field, a.tid, false); + // Redoing a field_delete means calling deleteField again. + // a.data should contain { field, deletedRelationships, modifiedRelationshipsOriginalState, previousFields, childFieldsSnapshot } + // The 'previousFields' in a.data is the state *before* the original deletion, which is what the *next* undo needs. + if (a.data && a.data.field && typeof a.data.tid !== 'undefined') { + actionForUndoStack.data = JSON.parse(JSON.stringify(a.data)); + + deleteField(a.data.field, a.data.tid, false); + } } else if (a.component === "field_add") { - updateTable(a.tid, { - fields: [ - ...tables[a.tid].fields, - { - name: "", - type: "", - default: "", - check: "", - primary: false, - unique: false, - notNull: false, - increment: false, - comment: "", - id: tables[a.tid].fields.length, - }, - ], - }); + if (tableBeforeRedo && a.data && a.data.fieldThatWasAdded) { + actionForUndoStack.data = { + ...a.data, + fieldsBeforeAdd: JSON.parse(JSON.stringify(tableBeforeRedo.fields)) + }; + const newFields = [...tableBeforeRedo.fields, JSON.parse(JSON.stringify(a.data.fieldThatWasAdded))]; + updateTable(a.tid, { fields: newFields.map((f, i) => ({ ...f, id: i })) }, false); + } } else if (a.component === "index_add") { - setTables((prev) => - prev.map((table) => { - if (table.id === a.tid) { - return { - ...table, - indices: [ - ...table.indices, - { - id: table.indices.length, - name: `index_${table.indices.length}`, - fields: [], - }, - ], - }; - } - return table; - }), - ); + if (tableBeforeRedo && a.data && a.data.indexToAdd) { + actionForUndoStack.data = { + ...a.data, + indicesBeforeAdd: JSON.parse(JSON.stringify(tableBeforeRedo.indices || [])) + }; + const newIndices = [...(tableBeforeRedo.indices || []), JSON.parse(JSON.stringify(a.data.indexToAdd))]; + updateTable(a.tid, { indices: newIndices.map((idx, i) => ({ ...idx, id: i })) }, false); + } } else if (a.component === "index") { - updateTable(a.tid, { - indices: tables[a.tid].indices.map((index) => - index.id === a.iid - ? { - ...index, - ...a.redo, - } - : index, - ), - }); + if (tableBeforeRedo) { + const indexBeforeRedo = tableBeforeRedo.indices.find(idx => idx.id === a.iid); + if (indexBeforeRedo) undoStateProperties = { undo: { ...indexBeforeRedo } }; + else if (a.undo) undoStateProperties = { undo: a.undo }; + updateTable(a.tid, { indices: tableBeforeRedo.indices.map(idx => idx.id === a.iid ? {...idx, ...a.redo} : idx ) }, false); + } } else if (a.component === "index_delete") { - updateTable(a.tid, { - indices: tables[a.tid].indices - .filter((e) => e.id !== a.data.id) - .map((t, i) => ({ ...t, id: i })), - }); + if (tableBeforeRedo && a.data && typeof a.data.id !== 'undefined') { + actionForUndoStack.data = JSON.parse(JSON.stringify(a.data)); + const newIndices = tableBeforeRedo.indices.filter(idx => idx.id !== a.data.id).map((idx, i) => ({...idx, id: i})); + updateTable(a.tid, { indices: newIndices }, false); + } } else if (a.component === "self") { + if (tableBeforeRedo) undoStateProperties = { undo: { ...tableBeforeRedo, ...a.undo } }; updateTable(a.tid, a.redo, false); } } else if (a.element === ObjectType.RELATIONSHIP) { - updateRelationship(a.rid, a.redo); + const relBeforeRedo = relationships.find(r => r.id === a.rid); + if (relBeforeRedo) undoStateProperties = { undo: { ...relBeforeRedo } }; + else if (a.undo) undoStateProperties = { undo: a.undo }; + updateRelationship(a.rid, a.redo, false); } else if (a.element === ObjectType.TYPE) { + const typeBeforeRedo = types.find(ty => ty.id === a.tid); if (a.component === "field_add") { - updateType(a.tid, { - fields: [ - ...types[a.tid].fields, - { - name: "", - type: "", - }, - ], - }); + // a.data.fieldToAdd was prepared by undo + if (typeBeforeRedo && a.data && a.data.fieldToAdd) { + actionForUndoStack.data = { + ...a.data, + fieldsBeforeAdd: JSON.parse(JSON.stringify(typeBeforeRedo.fields || [])) + }; + const newFields = [...(typeBeforeRedo.fields || []), JSON.parse(JSON.stringify(a.data.fieldToAdd))]; + updateType(a.tid, { fields: newFields.map((f, i) => ({ ...f, id: i })) }, false); + } } else if (a.component === "field") { - updateType(a.tid, { - fields: types[a.tid].fields.map((e, i) => - i === a.fid ? { ...e, ...a.redo } : e, - ), - }); + if (typeBeforeRedo) { + const fieldBeforeRedo = typeBeforeRedo.fields.find(f => f.id === a.fid); + if (fieldBeforeRedo) undoStateProperties = { undo: { ...fieldBeforeRedo } }; + else if (a.undo) undoStateProperties = { undo: a.undo }; + // Apply redo + updateType(a.tid, { + fields: typeBeforeRedo.fields.map(f => + f.id === a.fid ? { ...f, ...a.redo } : f, + ), + }, false); + } } else if (a.component === "field_delete") { - updateType(a.tid, { - fields: types[a.tid].fields.filter((field, i) => i !== a.fid), - }); - } else if (a.component === "self") { - updateType(a.tid, a.redo); - if (a.updatedFields) { - if (a.redo.name) { - a.updatedFields.forEach((x) => - updateField(x.tid, x.fid, { type: a.redo.name.toUpperCase() }), - ); - } + // Redoing a field_delete for a type field + // a.data is the field object that was deleted. + if (typeBeforeRedo && a.data && typeof a.data.id !== 'undefined') { + actionForUndoStack.data = JSON.parse(JSON.stringify(a.data)); // Save deleted field for next undo + const newFields = typeBeforeRedo.fields.filter(f => f.id !== a.data.id).map((f,i) => ({...f, id:i})); + updateType(a.tid, { fields: newFields }, false); } + } else if (a.component === "self") { + if (typeBeforeRedo) undoStateProperties = { undo: { ...typeBeforeRedo, ...a.undo } }; + updateType(a.tid, a.redo, false); } } else if (a.element === ObjectType.ENUM) { - updateEnum(a.id, a.redo); - if (a.updatedFields) { - if (a.redo.name) { - a.updatedFields.forEach((x) => - updateField(x.tid, x.fid, { type: a.redo.name.toUpperCase() }), - ); - } - } + const enumBeforeRedo = enums.find(en => en.id === a.id); + if (enumBeforeRedo) undoStateProperties = { undo: { ...enumBeforeRedo } }; + else if (a.undo) undoStateProperties = { undo: a.undo }; + updateEnum(a.id, a.redo, false); + // Similar to TYPE self, the updatedFields logic might be for undo } - setUndoStack((prev) => [...prev, a]); + + // Merge general undo properties if they were set and not handled by direct data manipulation + const componentHandledDataDirectly = + (a.element === ObjectType.TABLE && (a.component === "field_update" || a.component === "field_delete" || a.component === "field_add" || a.component === "index_add" || a.component === "index_delete")) || + (a.element === ObjectType.TYPE && (a.component === "field_add" || a.component === "field_delete")); + + if (Object.keys(undoStateProperties).length > 0 && !componentHandledDataDirectly) { + actionForUndoStack = { ...actionForUndoStack, ...undoStateProperties }; + } + setUndoStack((prev) => [...prev, actionForUndoStack]); } else if (a.action === Action.PAN) { - setTransform((prev) => ({ - ...prev, + actionForUndoStack = { ...a, undo: { ...transform.pan } }; + setTransform((prevTransform) => ({ + ...prevTransform, pan: a.redo, })); - setUndoStack((prev) => [...prev, a]); + setUndoStack((prev) => [...prev, actionForUndoStack]); } }; @@ -788,9 +944,18 @@ export default function ControlPanel({ .catch(() => Toast.error(t("oops_smth_went_wrong"))); }, }, - import_diagram: { - function: fileImport, - shortcut: "Ctrl+I", + import_from: { + children: [ + { + JSON: fileImport, + }, + { + DBML: () => { + setModal(MODAL.IMPORT); + setImportFrom(IMPORT_FROM.DBML); + }, + }, + ], }, import_from_source: { ...(database === DB.GENERIC && { @@ -985,6 +1150,21 @@ export default function ControlPanel({ setModal(MODAL.IMG); }, }, + { + SVG: () => { + const filter = (node) => node.tagName !== "i"; + toSvg(document.getElementById("canvas"), { filter: filter }).then( + function (dataUrl) { + setExportData((prev) => ({ + ...prev, + data: dataUrl, + extension: "svg", + })); + }, + ); + setModal(MODAL.IMG); + }, + }, { JSON: () => { setModal(MODAL.CODE); @@ -1010,18 +1190,18 @@ export default function ControlPanel({ }, }, { - SVG: () => { - const filter = (node) => node.tagName !== "i"; - toSvg(document.getElementById("canvas"), { filter: filter }).then( - function (dataUrl) { - setExportData((prev) => ({ - ...prev, - data: dataUrl, - extension: "svg", - })); - }, - ); - setModal(MODAL.IMG); + DBML: () => { + setModal(MODAL.CODE); + const result = toDBML({ + tables, + relationships, + enums, + }); + setExportData((prev) => ({ + ...prev, + data: result, + extension: "dbml", + })); }, }, { @@ -1044,30 +1224,6 @@ export default function ControlPanel({ }); }, }, - { - DRAWDB: () => { - const result = JSON.stringify( - { - author: "Unnamed", - title: title, - date: new Date().toISOString(), - tables: tables, - relationships: relationships, - notes: notes, - subjectAreas: areas, - database: database, - ...(databases[database].hasTypes && { types: types }), - ...(databases[database].hasEnums && { enums: enums }), - }, - null, - 2, - ); - const blob = new Blob([result], { - type: "text/plain;charset=utf-8", - }); - saveAs(blob, `${exportData.filename}.ddb`); - }, - }, { MERMAID: () => { setModal(MODAL.CODE); @@ -1252,6 +1408,26 @@ export default function ControlPanel({ showCardinality: !prev.showCardinality, })), }, + notation: { + children: [ + { + default_notation: () => { + setSettings((prev) => ({ ...prev, notation: Notation.DEFAULT })); + }, + }, + { + crows_foot_notation: () => { + setSettings((prev) => ({ ...prev, notation: Notation.CROWS_FOOT })); + }, + }, + { + idef1x_notation: () => { + setSettings((prev) => ({ ...prev, notation: Notation.IDEF1X })); + }, + }, + ], + function: () => {}, + }, show_relationship_labels: { state: settings.showRelationshipLabels ? ( @@ -1364,12 +1540,15 @@ export default function ControlPanel({ }, }, help: { - shortcuts: { - function: () => window.open("/shortcuts", "_blank"), + docs: { + function: () => window.open(`${socials.docs}`, "_blank"), shortcut: "Ctrl+H", }, + shortcuts: { + function: () => window.open(`${socials.docs}/shortcuts`, "_blank"), + }, ask_on_discord: { - function: () => window.open("https://discord.gg/BrjZgNrmR6", "_blank"), + function: () => window.open(socials.discord, "_blank"), }, report_bug: { function: () => window.open("/bug-report", "_blank"), @@ -1397,18 +1576,18 @@ export default function ControlPanel({ useHotkeys("ctrl+shift+m, meta+shift+m", viewStrictMode, { preventDefault: true, }); - useHotkeys("ctrl+shift+f, meta+shift+f", viewFieldSummary, { + useHotkeys("mod+shift+f", viewFieldSummary, { preventDefault: true, }); - useHotkeys("ctrl+shift+s, meta+shift+s", saveDiagramAs, { + useHotkeys("mod+shift+s", saveDiagramAs, { preventDefault: true, }); - useHotkeys("ctrl+alt+c, meta+alt+c", copyAsImage, { preventDefault: true }); - useHotkeys("ctrl+r, meta+r", resetView, { preventDefault: true }); - useHotkeys("ctrl+h, meta+h", () => window.open("/shortcuts", "_blank"), { + useHotkeys("mod+alt+c", copyAsImage, { preventDefault: true }); + useHotkeys("mod+r", resetView, { preventDefault: true }); + useHotkeys("mod+h", () => window.open(socials.docs, "_blank"), { preventDefault: true, }); - useHotkeys("ctrl+alt+w, meta+alt+w", fitWindow, { preventDefault: true }); + useHotkeys("mod+alt+w", fitWindow, { preventDefault: true }); return ( <> @@ -1442,6 +1621,7 @@ export default function ControlPanel({ setTitle={setTitle} setDiagramId={setDiagramId} setModal={setModal} + importFrom={importFrom} importDb={importDb} />
- {t(category)} + {t(category)}
))} diff --git a/src/components/EditorHeader/Modal/ImportDiagram.jsx b/src/components/EditorHeader/Modal/ImportDiagram.jsx index e84af2a30..b6d83872a 100644 --- a/src/components/EditorHeader/Modal/ImportDiagram.jsx +++ b/src/components/EditorHeader/Modal/ImportDiagram.jsx @@ -3,7 +3,7 @@ import { jsonDiagramIsValid, } from "../../../utils/validateSchema"; import { Upload, Banner } from "@douyinfe/semi-ui"; -import { DB, STATUS } from "../../../data/constants"; +import { DB, IMPORT_FROM, STATUS } from "../../../data/constants"; import { useAreas, useEnums, @@ -12,8 +12,14 @@ import { useTypes, } from "../../../hooks"; import { useTranslation } from "react-i18next"; +import { fromDBML } from "../../../utils/importFrom/dbml"; -export default function ImportDiagram({ setImportData, error, setError }) { +export default function ImportDiagram({ + setImportData, + error, + setError, + importFrom, +}) { const { areas } = useAreas(); const { notes } = useNotes(); const { tables, relationships, database } = useDiagram(); @@ -32,6 +38,125 @@ export default function ImportDiagram({ setImportData, error, setError }) { ); }; + const loadJsonData = (file, e) => { + let jsonObject = null; + try { + jsonObject = JSON.parse(e.target.result); + } catch (error) { + setError({ + type: STATUS.ERROR, + message: "The file contains an error.", + }); + return; + } + + if (file.type === "application/json") { + if (!jsonDiagramIsValid(jsonObject)) { + setError({ + type: STATUS.ERROR, + message: "The file is missing necessary properties for a diagram.", + }); + return; + } + } else if (file.name.split(".").pop() === "ddb") { + if (!ddbDiagramIsValid(jsonObject)) { + setError({ + type: STATUS.ERROR, + message: "The file is missing necessary properties for a diagram.", + }); + return; + } + } + + if (!jsonObject.database) { + jsonObject.database = DB.GENERIC; + } + + if (jsonObject.database !== database) { + setError({ + type: STATUS.ERROR, + message: + "The imported diagram and the open diagram don't use matching databases.", + }); + return; + } + + let ok = true; + jsonObject.relationships.forEach((rel) => { + if ( + !jsonObject.tables[rel.startTableId] || + !jsonObject.tables[rel.endTableId] + ) { + setError({ + type: STATUS.ERROR, + message: `Relationship ${rel.name} references a table that does not exist.`, + }); + ok = false; + return; + } + + if ( + !jsonObject.tables[rel.startTableId].fields[rel.startFieldId] || + !jsonObject.tables[rel.endTableId].fields[rel.endFieldId] + ) { + setError({ + type: STATUS.ERROR, + message: `Relationship ${rel.name} references a field that does not exist.`, + }); + ok = false; + return; + } + }); + + if (!ok) return; + + setImportData(jsonObject); + if (diagramIsEmpty()) { + setError({ + type: STATUS.OK, + message: "Everything looks good. You can now import.", + }); + } else { + setError({ + type: STATUS.WARNING, + message: + "The current diagram is not empty. Importing a new diagram will overwrite the current changes.", + }); + } + }; + + const loadDBMLData = (e) => { + try { + setImportData(fromDBML(e.target.result)); + } catch (error) { + const message = `${error.diags[0].name} [Ln ${error.diags[0].location.start.line}, Col ${error.diags[0].location.start.column}]: ${error.diags[0].message}`; + + setError({ type: STATUS.ERROR, message }); + } + }; + + const getAcceptableFileTypes = () => { + switch (importFrom) { + case IMPORT_FROM.JSON: + return "application/json,.ddb"; + case IMPORT_FROM.DBML: + return ".dbml"; + default: + return ""; + } + }; + + const getDragSubText = () => { + switch (importFrom) { + case IMPORT_FROM.JSON: + return `${t("supported_types")} JSON, DDB`; + case IMPORT_FROM.DBML: + return `${t("supported_types")} DBML`; + default: + return ""; + } + }; + return (
{ - let jsonObject = null; - try { - jsonObject = JSON.parse(e.target.result); - } catch (error) { - setError({ - type: STATUS.ERROR, - message: "The file contains an error.", - }); - return; - } - if (f.type === "application/json") { - if (!jsonDiagramIsValid(jsonObject)) { - setError({ - type: STATUS.ERROR, - message: - "The file is missing necessary properties for a diagram.", - }); - return; - } - } else if (f.name.split(".").pop() === "ddb") { - if (!ddbDiagramIsValid(jsonObject)) { - setError({ - type: STATUS.ERROR, - message: - "The file is missing necessary properties for a diagram.", - }); - return; - } - } - - if (!jsonObject.database) { - jsonObject.database = DB.GENERIC; - } - - if (jsonObject.database !== database) { - setError({ - type: STATUS.ERROR, - message: - "The imported diagram and the open diagram don't use matching databases.", - }); - return; - } - - let ok = true; - jsonObject.relationships.forEach((rel) => { - if ( - !jsonObject.tables[rel.startTableId] || - !jsonObject.tables[rel.endTableId] - ) { - setError({ - type: STATUS.ERROR, - message: `Relationship ${rel.name} references a table that does not exist.`, - }); - ok = false; - return; - } - - if ( - !jsonObject.tables[rel.startTableId].fields[rel.startFieldId] || - !jsonObject.tables[rel.endTableId].fields[rel.endFieldId] - ) { - setError({ - type: STATUS.ERROR, - message: `Relationship ${rel.name} references a field that does not exist.`, - }); - ok = false; - return; - } - }); - - if (!ok) return; - - setImportData(jsonObject); - if (diagramIsEmpty()) { - setError({ - type: STATUS.OK, - message: "Everything looks good. You can now import.", - }); - } else { - setError({ - type: STATUS.WARNING, - message: - "The current diagram is not empty. Importing a new diagram will overwrite the current changes.", - }); - } + if (importFrom == IMPORT_FROM.JSON) loadJsonData(f, e); + if (importFrom == IMPORT_FROM.DBML) loadDBMLData(e); }; reader.readAsText(f); @@ -140,8 +182,8 @@ export default function ImportDiagram({ setImportData, error, setError }) { }} draggable={true} dragMainText={t("drag_and_drop_files")} - dragSubText={t("support_json_and_ddb")} - accept="application/json,.ddb" + dragSubText={getDragSubText()} + accept={getAcceptableFileTypes()} onRemove={() => setError({ type: STATUS.NONE, diff --git a/src/components/EditorHeader/Modal/Modal.jsx b/src/components/EditorHeader/Modal/Modal.jsx index 8db36c466..270172e67 100644 --- a/src/components/EditorHeader/Modal/Modal.jsx +++ b/src/components/EditorHeader/Modal/Modal.jsx @@ -48,6 +48,7 @@ export default function Modal({ exportData, setExportData, importDb, + importFrom, }) { const { t, i18n } = useTranslation(); const { setTables, setRelationships, database, setDatabase } = useDiagram(); @@ -75,8 +76,8 @@ export default function Modal({ const overwriteDiagram = () => { setTables(importData.tables); setRelationships(importData.relationships); - setAreas(importData.subjectAreas); - setNotes(importData.notes); + setAreas(importData.subjectAreas ?? []); + setNotes(importData.notes ?? []); if (importData.title) { setTitle(importData.title); } @@ -247,6 +248,7 @@ export default function Modal({ setImportData={setImportData} error={error} setError={setError} + importFrom={importFrom} /> ); case MODAL.IMPORT_SRC: diff --git a/src/components/EditorSidePanel/RelationshipsTab/RelationshipInfo.jsx b/src/components/EditorSidePanel/RelationshipsTab/RelationshipInfo.jsx index 3cfc4516b..4faeb1804 100644 --- a/src/components/EditorSidePanel/RelationshipsTab/RelationshipInfo.jsx +++ b/src/components/EditorSidePanel/RelationshipsTab/RelationshipInfo.jsx @@ -6,6 +6,7 @@ import { Popover, Table, Input, + Checkbox, } from "@douyinfe/semi-ui"; import { IconDeleteStroked, @@ -13,16 +14,19 @@ import { IconMore, } from "@douyinfe/semi-icons"; import { - Cardinality, + RelationshipType, + RelationshipCardinalities, Constraint, + SubtypeRestriction, Action, ObjectType, + Notation, } from "../../../data/constants"; -import { useDiagram, useUndoRedo } from "../../../hooks"; +import { useDiagram, useUndoRedo} from "../../../hooks"; import i18n from "../../../i18n/i18n"; import { useTranslation } from "react-i18next"; import { useState } from "react"; - +import { useSettings } from "../../../hooks"; const columns = [ { title: i18n.t("primary"), @@ -36,10 +40,11 @@ const columns = [ export default function RelationshipInfo({ data }) { const { setUndoStack, setRedoStack } = useUndoRedo(); - const { tables, setRelationships, deleteRelationship, updateRelationship } = + const { tables, setTables, setRelationships, deleteRelationship, updateRelationship } = useDiagram(); const { t } = useTranslation(); const [editField, setEditField] = useState({}); + const { settings } = useSettings(); const swapKeys = () => { setUndoStack((prev) => [ @@ -85,6 +90,42 @@ export default function RelationshipInfo({ data }) { ); }; + const changeRelationshipType = (value) => { + const defaultCardinality = + RelationshipCardinalities[value] && RelationshipCardinalities[value][0] + ? RelationshipCardinalities[value][0].label + : ""; + + setUndoStack((prev) => [ + ...prev, + { + action: Action.EDIT, + element: ObjectType.RELATIONSHIP, + rid: data.id, + undo: { + relationshipType: data.relationshipType, + cardinality: data.cardinality, + }, + redo: { + relationshipType: value, + cardinality: defaultCardinality, + }, + message: t("edit_relationship", { + refName: data.name, + extra: "[relationship type]", + }), + }, + ]); + setRedoStack([]); + setRelationships((prev) => + prev.map((e, idx) => + idx === data.id + ? { ...e, relationshipType: value, cardinality: defaultCardinality } + : e, + ), + ); + }; + const changeCardinality = (value) => { setUndoStack((prev) => [ ...prev, @@ -108,6 +149,54 @@ export default function RelationshipInfo({ data }) { ); }; + const changeSubtypeRestriction = (value) => { + setUndoStack((prev) => [ + ...prev, + { + action: Action.EDIT, + element: ObjectType.RELATIONSHIP, + rid: data.id, + undo: { subtype_restriction: data.subtype_restriction }, + redo: { subtype_restriction: value }, + message: t("edit_relationship", { + refName: data.name, + extra: "[subtype_restriction]", + }), + }, + ]); + setRedoStack([]); + setRelationships((prev) => + prev.map((e, idx) => + idx === data.id ? { ...e, subtype_restriction: value } : e, + ), + ); + } + + const toggleSubtype = () => { + const prevVal = data.subtype; + setUndoStack((prev) => [ + ...prev, + { + action: Action.EDIT, + element: ObjectType.RELATIONSHIP, + rid: data.id, + undo: { subtype: data.subtype }, + redo: { subtype: !data.subtype }, + message: t("edit_relationship", { + refName: data.name, + extra: "[subtype]", + }), + }, + ]); + + setRedoStack([]); + setRelationships((prev) => + prev.map((e, idx) => + idx === data.id ? { ...e, subtype: !prevVal } : e, + ), + ); + }; + const changeConstraint = (key, value) => { const undoKey = `${key}Constraint`; setUndoStack((prev) => [ @@ -130,6 +219,57 @@ export default function RelationshipInfo({ data }) { ); }; + const toggleParentCardinality = () => { + const startTable = tables.find((t) => t.id === data.endTableId); + if (!startTable) return; + + const fkFieldIds = Array.isArray(data.endFieldId) + ? data.endFieldId + : [data.endFieldId]; + + const fkFields = startTable.fields.filter((f) => fkFieldIds.includes(f.id)); + if (fkFields.length === 0) return; + + const isCurrentlyNullable = fkFields.every((field) => !field.notNull); + const newNotNullValue = isCurrentlyNullable; + + const undoFields = fkFields.map(field => ({ id: field.id, notNull: field.notNull })); + const redoFields = fkFields.map(field => ({ id: field.id, notNull: newNotNullValue })); + + setUndoStack((prev) => [ + ...prev, + { + action: Action.EDIT, + element: ObjectType.TABLE, + tid: startTable.id, + undo: { fields: undoFields }, + redo: { fields: redoFields }, + message: t("edit_relationship", { + refName: data.name, + extra: "[parent cardinality]", + }), + }, + ]); + setRedoStack([]); + + setTables((prevTables) => + prevTables.map((table) => { + if (table.id === startTable.id) { + return { + ...table, + fields: table.fields.map((field) => { + if (fkFieldIds.includes(field.id)) { + return { ...field, notNull: newNotNullValue }; + } + return field; + }), + }; + } + return table; + }), + ); + }; + return ( <>
@@ -212,16 +352,73 @@ export default function RelationshipInfo({ data }) {
-
{t("cardinality")}:
+
{t("relationship_type")}:
({ + label: c.label, + value: c.label, + })) + } + value={data.cardinality} + className="w-full" + onChange={changeCardinality} + disabled={!data.relationshipType} + placeholder={t("select_cardinality")} + /> + {(settings.notation === Notation.CROWS_FOOT || + settings.notation === Notation.IDEF1X) && ( +
+ + {/* 👇 Aquí ya es otra fila separada */} + + +
{t("subtype")}:
+ + + + +
+ + {data.subtype && ( + +
+ {t("subtype_restriction")}: +
+