diff --git a/src/components/EditorCanvas/Canvas.jsx b/src/components/EditorCanvas/Canvas.jsx index 7a9c4aaef..ac66fc56c 100644 --- a/src/components/EditorCanvas/Canvas.jsx +++ b/src/components/EditorCanvas/Canvas.jsx @@ -222,6 +222,13 @@ export default function Canvas() { newName: "", }); + const [changeCardinalityModal, setChangeCardinalityModal] = useState({ + visible: false, + relationshipId: null, + currentCardinality: "", + newCardinality: "", + }); + const [areaRenameModal, setAreaRenameModal] = useState({ visible: false, areaId: null, @@ -255,6 +262,7 @@ export default function Canvas() { const fieldRenameInputRef = useRef(null); const tableRenameInputRef = useRef(null); const relationshipRenameInputRef = useRef(null); + const changeCardinalityInputRef = useRef(null); // Auto-select text when field rename modal opens useEffect(() => { @@ -295,6 +303,19 @@ export default function Canvas() { } }, [relationshipRenameModal.visible]); + // Auto select when the change cardinality modal opens + useEffect(() => { + if (changeCardinalityModal.visible && changeCardinalityInputRef.current) { + const timer = setTimeout(() => { + if (changeCardinalityInputRef.current) { + changeCardinalityInputRef.current.focus(); + changeCardinalityInputRef.current.select(); + } + }, 100); + return () => clearTimeout(timer); + } + }, [changeCardinalityModal.visible]); + // Centralized function to close all context menus const closeAllContextMenus = () => { setContextMenu({ @@ -477,6 +498,46 @@ export default function Canvas() { }); }; + const handleChangeCardinalityConfirm = () => { + const { relationshipId, newCardinality } = changeCardinalityModal; + const relationship = relationships.find((r) => r.id === relationshipId); + + if (!relationship) return; + + const trimmedCardinality = newCardinality.trim(); + if (trimmedCardinality === "") { + handleChangeCardinalityCancel(); + return; + } + + const validation = validateCustomCardinality(trimmedCardinality); + if (!validation.valid) { + Toast.error(validation.message); + return; + } + + const normalizedCardinality = normalizeCardinality(trimmedCardinality); + updateRelationship(relationshipId, { cardinality: normalizedCardinality }); + + setChangeCardinalityModal({ + visible: false, + relationshipId: null, + currentCardinality: "", + newCardinality: "", + }); + + Toast.success("Cardinality updated."); + }; + + const handleChangeCardinalityCancel = () => { + setChangeCardinalityModal({ + visible: false, + relationshipId: null, + currentCardinality: "", + newCardinality: "", + }); + }; + const handleAddField = () => { if (contextMenu.tableId !== null) { const table = tables.find((t) => t.id === contextMenu.tableId); @@ -729,6 +790,7 @@ export default function Canvas() { const handleRenameRelationship = () => { if (relationshipContextMenu.relationshipId !== null) { + // Find relationship by id const relationship = relationships.find( (r) => r.id === relationshipContextMenu.relationshipId, ); @@ -942,27 +1004,71 @@ export default function Canvas() { if (relationshipContextMenu.relationshipId !== null) { const relationship = relationships[relationshipContextMenu.relationshipId]; - if (relationship) { - pushUndo({ - action: Action.EDIT, - element: ObjectType.RELATIONSHIP, - rid: relationshipContextMenu.relationshipId, - undo: { cardinality: relationship.cardinality }, - redo: { cardinality: newCardinality }, - message: `Change cardinality for ${relationship.name}`, + if (!relationship) { + handleRelationshipContextMenuClose(); + return; + } + + if (newCardinality === "Custom...") { + setChangeCardinalityModal({ + visible: true, + relationshipId: relationshipContextMenu.relationshipId, + currentCardinality: relationship.cardinality || "", + newCardinality: relationship.cardinality || "", + }); + } else { + updateRelationship(relationshipContextMenu.relationshipId, { + cardinality: newCardinality, }); - setRelationships((prev) => - prev.map((e, idx) => - idx === relationshipContextMenu.relationshipId - ? { ...e, cardinality: newCardinality } - : e, - ), - ); } handleRelationshipContextMenuClose(); } }; + const validateCustomCardinality = (cardinality) => { + const match = cardinality.match(/^\(\s*(\d+)\s*,\s*(\d+|\*)\s*\)$/); + if (!match) { + return { + valid: false, + message: "Invalid format. Use (x,y) where x is 0 or 1, and y is greater than 1 or *", + }; + } + + const first = parseInt(match[1]); + const second = match[2]; + + // First coordinate must be 0 or 1 + if (first !== 0 && first !== 1) { + return { + valid: false, + message: "First coordinate must be 0 or 1", + }; + } + + // Second coordinate must be * or a number greater than 1 + if (second !== "*") { + const secondNum = parseInt(second); + if (isNaN(secondNum) || secondNum < 2) { + return { + valid: false, + message: "Second coordinate must be greater than 1 or *", + }; + } + } + + return { valid: true }; + }; + + const normalizeCardinality = (cardinality) => { + const match = cardinality.match(/^\(\s*(\d+)\s*,\s*(\d+|\*)\s*\)$/); + if (!match) return cardinality; + + const first = parseInt(match[1]); + const second = match[2] === "*" ? "*" : parseInt(match[2]).toString(); + + return `(${first},${second})`; + }; + const handleDeleteRelationship = () => { if (relationshipContextMenu.relationshipId !== null) { deleteRelationship(relationshipContextMenu.relationshipId); @@ -1033,7 +1139,6 @@ export default function Canvas() { } }; - // Area context menu handlers const handleAreaContextMenu = (e, areaId, x, y) => { e.preventDefault(); e.stopPropagation(); @@ -1155,7 +1260,6 @@ export default function Canvas() { } }; - // Note context menu handlers const handleNoteContextMenu = (e, noteId, x, y) => { e.preventDefault(); e.stopPropagation(); @@ -1376,12 +1480,9 @@ export default function Canvas() { relatedTableId, relatedFieldId, ) => { - // Rename the original field renameFieldOnly(tableId, fieldId, newName); - // Rename the related field renameFieldOnly(relatedTableId, relatedFieldId, newName); - // Update relationship names to default when foreign key fields are renamed updateRelationshipNamesAfterFieldRename( tableId, fieldId, @@ -3261,6 +3362,51 @@ export default function Canvas() { + +
+ + + setChangeCardinalityModal((prev) => ({ + ...prev, + newCardinality: value, + })) + } + placeholder={"e.g., (1,n)"} + onEnterPress={handleChangeCardinalityConfirm} + /> + + {/* ADD THIS SECTION FOR THE LEGEND */} +
+

Format: (x,y) where x is 0 or 1, and y is n, *, or a number.

+
+
+
+ 0) { dx = vectorInfo.dx / magnitude; dy = vectorInfo.dy / magnitude; @@ -190,27 +195,27 @@ export function CrowsFootChild( // For vertical relationships, position cardinalities to the side return { x: isStart ? x - 25 : x + 35, // Child side (end) further away from table for clarity - y: y // Same vertical level as the notation + y: y, // Same vertical level as the notation }; } else { return { x: isStart ? x - 8 : x + 15, - y: y - 20 // Above the line for horizontal relationships + y: y - 20, // Above the line for horizontal relationships }; } }; // Base distances from table edge - CORRECTED ORDER // Note: cardinalityEndX/Y are already offset by 35px from table edge in Relationship.jsx - const crowsFootOffset = 6; // Crow's foot closer to table (reduced due to existing 35px offset) - const elementOffset = 12; // Bars/circles further from table (reduced due to existing 35px offset) - const lineLength = 8; // Half length of bars + const crowsFootOffset = 6; // Crow's foot closer to table (reduced due to existing 35px offset) + const elementOffset = 12; // Bars/circles further from table (reduced due to existing 35px offset) + const lineLength = 8; // Half length of bars // Calculate positions along the line direction - const crowsFootX = cardinalityEndX - (dx * crowsFootOffset); - const crowsFootY = cardinalityEndY - (dy * crowsFootOffset); - const elementX = cardinalityEndX - (dx * elementOffset); - const elementY = cardinalityEndY - (dy * elementOffset); + const crowsFootX = cardinalityEndX - dx * crowsFootOffset; + const crowsFootY = cardinalityEndY - dy * crowsFootOffset; + const elementX = cardinalityEndX - dx * elementOffset; + const elementY = cardinalityEndY - dy * elementOffset; return ( pathRef && ( @@ -233,10 +238,10 @@ export function CrowsFootChild( {isMandatory && isMany && ( <> {(() => { - const startPos = getCardinalityPosition(cardinalityStartX, cardinalityStartY, true); - const endPos = getCardinalityPosition(cardinalityEndX, cardinalityEndY, false); + const startPos = getCardinalityPosition( + cardinalityStartX, + cardinalityStartY, + true, + ); + const endPos = getCardinalityPosition( + cardinalityEndX, + cardinalityEndY, + false, + ); return ( <> - - - {cardinalityStart} - - - - {cardinalityEnd} - - - ) - ) + return ( + pathRef && ( + + + + {cardinalityStart} + + + + {cardinalityEnd} + + + ) + ); } export function IDEFZM( @@ -423,23 +436,29 @@ export function IDEFZM( cardinalityEnd, showCardinality = true, isVertical = false, - vectorInfo = null + vectorInfo = null, ) { let letter = null; - switch (cardinalityEnd) { - case "(1,*)": - letter = "P"; - break; - case "(1,1)": - letter = "L"; - break; - case "(0,1)": - letter = "Z"; - break; + + if (cardinalityEnd.startsWith("(1") && cardinalityEnd.endsWith("1)")) { + letter = "L"; + } else if ( + cardinalityEnd.startsWith("(1") && + (cardinalityEnd.endsWith("*)") || + (cardinalityEnd.includes(",") && !cardinalityEnd.endsWith("1)"))) + ) { + // (1,*) or (1,X) where X is not 1 - one to many (like (1,10), (1,5)) + letter = "P"; + } else if (cardinalityEnd.startsWith("(0") && cardinalityEnd.endsWith("1)")) { + // (0,1) - zero or one + letter = "Z"; } - let dx = 1, dy = 0; + let dx = 1, + dy = 0; if (vectorInfo) { - const magnitude = Math.sqrt(vectorInfo.dx * vectorInfo.dx + vectorInfo.dy * vectorInfo.dy); + const magnitude = Math.sqrt( + vectorInfo.dx * vectorInfo.dx + vectorInfo.dy * vectorInfo.dy, + ); if (magnitude > 0) { dx = vectorInfo.dx / magnitude; dy = vectorInfo.dy / magnitude; @@ -448,13 +467,13 @@ export function IDEFZM( // Position circle along the line direction, away from table const circleOffset = 8; - const circleX = cardinalityEndX - (dx * circleOffset); - const circleY = cardinalityEndY - (dy * circleOffset); + const circleX = cardinalityEndX - dx * circleOffset; + const circleY = cardinalityEndY - dy * circleOffset; // Position letter further away from table const letterOffset = 15; - const letterX = cardinalityEndX - (dx * letterOffset); - const letterY = cardinalityEndY - (dy * letterOffset) + 4; // Small adjustment for text baseline + const letterX = cardinalityEndX - dx * letterOffset; + const letterY = cardinalityEndY - dy * letterOffset + 4; // Small adjustment for text baseline return ( pathRef && ( diff --git a/src/components/EditorSidePanel/RelationshipsTab/RelationshipInfo.jsx b/src/components/EditorSidePanel/RelationshipsTab/RelationshipInfo.jsx index e5aed5f90..b35e03a6e 100644 --- a/src/components/EditorSidePanel/RelationshipsTab/RelationshipInfo.jsx +++ b/src/components/EditorSidePanel/RelationshipsTab/RelationshipInfo.jsx @@ -26,7 +26,7 @@ import { import { useDiagram, useSettings, useUndoRedo } from "../../../hooks"; import i18n from "../../../i18n/i18n"; import { useTranslation } from "react-i18next"; -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; const columns = [ { title: i18n.t("primary"), @@ -51,6 +51,17 @@ export default function RelationshipInfo({ data }) { } = useDiagram(); const { t } = useTranslation(); const [editField, setEditField] = useState({}); + const [customCardinality, setCustomCardinality] = useState(""); + const customInputRef = useRef(null); + + // Auto focus on custom cardinality input when it appears + useEffect(() => { + if (customCardinality !== "" && customInputRef.current) { + setTimeout(() => { + customInputRef.current?.focus(); + }, 0); + } + }, [customCardinality]); // Helper function to get the effective end table ID and field ID const getEffectiveEndTable = () => { if (data.endTableId !== undefined) { @@ -219,6 +230,11 @@ export default function RelationshipInfo({ data }) { }; const changeCardinality = (value) => { + if (value === "Custom...") { + setCustomCardinality(data.cardinality || ""); + return; + } + pushUndo({ action: Action.EDIT, element: ObjectType.RELATIONSHIP, @@ -237,6 +253,112 @@ export default function RelationshipInfo({ data }) { ); }; + const handleCustomCardinalityChange = (value) => { + setCustomCardinality(value); + }; + + const handleCustomCardinalityKeyDown = (e) => { + if (e.key === "Enter") { + e.preventDefault(); + applyCustomCardinality(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelCustomCardinality(); + } + }; + + const validateCustomCardinality = (cardinality) => { + // Check if it matches the pattern (x,y) where x and y are numbers + const match = cardinality.match(/^\(\s*(\d+)\s*,\s*(\d+|\*)\s*\)$/); + if (!match) { + return { + valid: false, + message: t( + "cardinality_format_error", + "Invalid format. Use (x,y) where x is 0 or 1, and y is greater than 1 or *", + ), + }; + } + + const first = parseInt(match[1]); + const second = match[2]; + + // First coordinate must be 0 or 1 + if (first !== 0 && first !== 1) { + return { + valid: false, + message: t( + "cardinality_first_error", + "First coordinate must be 0 or 1", + ), + }; + } + + // Second coordinate must be * or a number greater than 1 + if (second !== "*") { + const secondNum = parseInt(second); + if (isNaN(secondNum) || secondNum < 2) { + return { + valid: false, + message: t( + "cardinality_second_error", + "Second coordinate must be greater than 1 or *", + ), + }; + } + } + + return { valid: true }; + }; + + const normalizeCardinality = (cardinality) => { + const match = cardinality.match(/^\(\s*(\d+)\s*,\s*(\d+|\*)\s*\)$/); + if (!match) return cardinality; + + const first = parseInt(match[1]); + const second = match[2] === "*" ? "*" : parseInt(match[2]).toString(); + + return `(${first},${second})`; + }; + + const applyCustomCardinality = () => { + const trimmedCardinality = customCardinality.trim(); + if (trimmedCardinality === "") { + return; + } + + // Validate the custom cardinality + const validation = validateCustomCardinality(trimmedCardinality); + if (!validation.valid) { + Toast.error(validation.message); + return; + } + + const normalizedCardinality = normalizeCardinality(trimmedCardinality); + const timestamp = Date.now(); + pushUndo({ + action: Action.EDIT, + element: ObjectType.RELATIONSHIP, + rid: data.id, + undo: { cardinality: data.cardinality }, + redo: { cardinality: normalizedCardinality }, + message: t("edit_relationship", { + refName: data.name, + extra: `[cardinality-${timestamp}]`, + }), + }); + setRelationships((prev) => + prev.map((e, idx) => + idx === data.id ? { ...e, cardinality: normalizedCardinality } : e, + ), + ); + setCustomCardinality(""); + }; + + const cancelCustomCardinality = () => { + setCustomCardinality(""); + }; + const changeSubtypeRestriction = (value) => { pushUndo({ action: Action.EDIT, @@ -504,31 +626,73 @@ export default function RelationshipInfo({ data }) { data.relationshipType !== RelationshipType.SUBTYPE && ( <>
{t("cardinality")}:
-
- 1 or *). E.g., (0,5), (1,*)" + } + className="w-full" /> - )} -
+
+ Format: (x,y) where x is 0 or 1, and y is greater than 1 or * +
+ Press Enter to apply, Escape to cancel +
+
+ + +
+ + ) : ( + // Normal cardinality selection +
+