diff --git a/src/components/select-box/select-box.tsx b/src/components/select-box/select-box.tsx index 9f14adf2..6f08c930 100644 --- a/src/components/select-box/select-box.tsx +++ b/src/components/select-box/select-box.tsx @@ -58,6 +58,7 @@ export interface SelectBoxProps { footerButtons?: React.ReactNode; commandOnMouseDown?: (e: React.MouseEvent) => void; commandOnClick?: (e: React.MouseEvent) => void; + onSearchChange?: (search: string) => void; } export const SelectBox = React.forwardRef( @@ -87,6 +88,7 @@ export const SelectBox = React.forwardRef( footerButtons, commandOnMouseDown, commandOnClick, + onSearchChange, }, ref ) => { @@ -404,7 +406,10 @@ export const SelectBox = React.forwardRef(
setSearchTerm(e)} + onValueChange={(e) => { + setSearchTerm(e); + onSearchChange?.(e); + }} ref={ref} placeholder={inputPlaceholder ?? 'Search...'} className="h-9" diff --git a/src/dialogs/select-relationship-fields-dialog/select-relationship-fields-dialog.tsx b/src/dialogs/select-relationship-fields-dialog/select-relationship-fields-dialog.tsx new file mode 100644 index 00000000..a7773a85 --- /dev/null +++ b/src/dialogs/select-relationship-fields-dialog/select-relationship-fields-dialog.tsx @@ -0,0 +1,218 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button } from '@/components/button/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/dialog/dialog'; +import { useChartDB } from '@/hooks/use-chartdb'; +import type { SelectBoxOption } from '@/components/select-box/select-box'; +import { SelectBox } from '@/components/select-box/select-box'; +import { useReactFlow } from '@xyflow/react'; +import { areFieldTypesCompatible } from '@/lib/data/data-types/data-types'; +import { useLayout } from '@/hooks/use-layout'; + +export interface SelectRelationshipFieldsDialogProps { + open: boolean; + sourceTableId: string; + targetTableId: string; + onClose: () => void; +} + +export const SelectRelationshipFieldsDialog: React.FC< + SelectRelationshipFieldsDialogProps +> = ({ open, sourceTableId, targetTableId, onClose }) => { + const { getTable, createRelationship, databaseType } = useChartDB(); + const { fitView, setEdges } = useReactFlow(); + const { openRelationshipFromSidebar } = useLayout(); + const [targetFieldId, setTargetFieldId] = useState(); + const [errorMessage, setErrorMessage] = useState(''); + + const sourceTable = useMemo( + () => getTable(sourceTableId), + [sourceTableId, getTable] + ); + const targetTable = useMemo( + () => getTable(targetTableId), + [targetTableId, getTable] + ); + + // Get the PK field from source table + const sourcePKField = useMemo(() => { + if (!sourceTable) return null; + return ( + sourceTable.fields.find((f) => f.primaryKey) || + sourceTable.fields[0] + ); + }, [sourceTable]); + + // Get compatible target fields (FK columns) + const targetFieldOptions = useMemo(() => { + if (!targetTable || !sourcePKField) return []; + + return targetTable.fields + .filter((field) => + areFieldTypesCompatible( + sourcePKField.type, + field.type, + databaseType + ) + ) + .map( + (field) => + ({ + label: field.name, + value: field.id, + description: `(${field.type.name})`, + }) as SelectBoxOption + ); + }, [targetTable, sourcePKField, databaseType]); + + // Auto-select first compatible field + useEffect(() => { + if (open && targetFieldOptions.length > 0 && !targetFieldId) { + setTargetFieldId(targetFieldOptions[0].value as string); + } + }, [open, targetFieldOptions, targetFieldId]); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setTargetFieldId(undefined); + setErrorMessage(''); + } + }, [open]); + + const handleCreate = useCallback(async () => { + if (!sourcePKField || !targetFieldId) return; + + try { + const relationship = await createRelationship({ + sourceTableId, + sourceFieldId: sourcePKField.id, + targetTableId, + targetFieldId, + }); + + setEdges((edges) => + edges.map((edge) => + edge.id === relationship.id + ? { ...edge, selected: true } + : { ...edge, selected: false } + ) + ); + + fitView({ + duration: 500, + maxZoom: 1, + minZoom: 1, + nodes: [ + { id: relationship.sourceTableId }, + { id: relationship.targetTableId }, + ], + }); + + openRelationshipFromSidebar(relationship.id); + onClose(); + } catch (error) { + console.error(error); + setErrorMessage('Failed to create relationship'); + } + }, [ + sourcePKField, + targetFieldId, + sourceTableId, + targetTableId, + createRelationship, + setEdges, + fitView, + openRelationshipFromSidebar, + onClose, + ]); + + if (!sourceTable || !targetTable || !sourcePKField) { + return null; + } + + return ( + !isOpen && onClose()} + modal={false} + > + e.preventDefault()} + > + + Select Relationship Fields + +
+
+ {/* PK Column (Source) */} +
+
+ PK Column +
+
+ {sourcePKField.name} +
+
+ + {/* FK Column (Target) */} +
+
+ FK Column +
+ + setTargetFieldId(value as string) + } + emptyPlaceholder="No compatible fields" + /> +
+
+ + {errorMessage && ( +

{errorMessage}

+ )} + + {targetFieldOptions.length === 0 && ( +

+ No compatible fields found in target table +

+ )} +
+ + + + + + +
+
+ ); +}; diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index 8a396e0b..66cdaf0a 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -93,6 +93,8 @@ import { filterTable } from '@/lib/domain/diagram-filter/filter'; import { defaultSchemas } from '@/lib/data/default-schemas'; import { useDiff } from '@/context/diff-context/use-diff'; import { useClickAway } from 'react-use'; +import { SelectRelationshipFieldsOverlay } from './select-relationship-fields-overlay/select-relationship-fields-overlay'; +import { TABLE_RELATIONSHIP_HANDLE_ID_PREFIX } from './table-node/table-node'; const HIGHLIGHTED_EDGE_Z_INDEX = 1; const DEFAULT_EDGE_Z_INDEX = 0; @@ -123,12 +125,21 @@ const tableToTableNode = ( filterLoading, showDBViews, forceShow, + onStartRelationship, + pendingRelationshipSource, + fieldSelectionDialog, }: { filter?: DiagramFilter; databaseType: DatabaseType; filterLoading: boolean; showDBViews?: boolean; forceShow?: boolean; + onStartRelationship?: (sourceTableId: string) => void; + pendingRelationshipSource?: string | null; + fieldSelectionDialog?: { + sourceTableId: string; + targetTableId: string; + } | null; } ): TableNodeType => { // Always use absolute position for now @@ -149,6 +160,13 @@ const tableToTableNode = ( (!showDBViews && table.isView); } + // Check if this table is the source or target in the field selection dialog + const isDialogSource = fieldSelectionDialog?.sourceTableId === table.id; + const isDialogTarget = fieldSelectionDialog?.targetTableId === table.id; + + // Check if this table is the source during pending relationship selection + const isPendingRelationshipSource = pendingRelationshipSource === table.id; + return { id: table.id, type: 'table', @@ -156,6 +174,13 @@ const tableToTableNode = ( data: { table, isOverlapping: false, + onStartRelationship, + isPendingRelationshipTarget: + pendingRelationshipSource !== null && + pendingRelationshipSource !== table.id, + isDialogSource, + isDialogTarget, + isPendingRelationshipSource, }, width: table.width ?? MIN_TABLE_SIZE, hidden, @@ -253,6 +278,20 @@ export const Canvas: React.FC = ({ initialTables }) => { const { filter, loading: filterLoading } = useDiagramFilter(); const { checkIfNewTable } = useDiff(); + // State for field selection dialog when using right-click "Add Relationship" + const [fieldSelectionDialog, setFieldSelectionDialog] = useState<{ + sourceTableId: string; + targetTableId: string; + } | null>(null); + + // State to track pending relationship source when user clicks "Add Relationship" + const [pendingRelationshipSource, setPendingRelationshipSource] = useState< + string | null + >(null); + + // Track if a connection is in progress + const [isConnecting, setIsConnecting] = useState(false); + const shouldForceShowTable = useCallback( (tableId: string) => { return checkIfNewTable({ tableId }); @@ -262,6 +301,7 @@ export const Canvas: React.FC = ({ initialTables }) => { const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true); + // Initialize nodes without the callback first const [nodes, setNodes, onNodesChange] = useNodesState( initialTables.map((table) => tableToTableNode(table, { @@ -270,12 +310,58 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, showDBViews, forceShow: shouldForceShowTable(table.id), + onStartRelationship: undefined, // Will be set later }) ) ); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + // Callback to handle "Add Relationship" from context menu + const handleStartRelationship = useCallback( + (sourceTableId: string) => { + console.log( + '[Canvas] handleStartRelationship called for table:', + sourceTableId + ); + + // Close filter if it's open + if (showFilter) { + setShowFilter(false); + } + + setPendingRelationshipSource(sourceTableId); + + // Add a temporary visual edge to show the pending connection + const tempEdgeId = 'temp-pending-relationship'; + setEdges((edges) => [ + ...edges.filter((e) => e.id !== tempEdgeId), + + { + id: tempEdgeId, + source: sourceTableId, + target: sourceTableId, // Initially point to self, will update on mouse move + type: 'default', + animated: true, + style: { + stroke: '#3b82f6', + strokeWidth: 2, + strokeDasharray: '5 5', + }, + interactionWidth: 0, // Make it non-interactive + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ]); + + // Show instruction to user + toast({ + title: 'Select Target Table', + description: 'Click on the table you want to connect to', + }); + }, + [toast, setEdges, showFilter, setShowFilter] + ); + const [snapToGridEnabled, setSnapToGridEnabled] = useState(false); useEffect(() => { @@ -290,6 +376,7 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, showDBViews, forceShow: shouldForceShowTable(table.id), + onStartRelationship: handleStartRelationship, }) ); if (equal(initialNodes, nodes)) { @@ -303,6 +390,7 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, showDBViews, shouldForceShowTable, + handleStartRelationship, ]); useEffect(() => { @@ -336,31 +424,39 @@ export const Canvas: React.FC = ({ initialTables }) => { {} as Record ); - setEdges([ - ...relationships.map( - (relationship): RelationshipEdgeType => ({ - id: relationship.id, - source: relationship.sourceTableId, - target: relationship.targetTableId, - sourceHandle: `${LEFT_HANDLE_ID_PREFIX}${relationship.sourceFieldId}`, - targetHandle: `${TARGET_ID_PREFIX}${targetIndexes[`${relationship.targetTableId}${relationship.targetFieldId}`]++}_${relationship.targetFieldId}`, - type: 'relationship-edge', - data: { relationship }, - }) - ), - ...dependencies.map( - (dep): DependencyEdgeType => ({ - id: dep.id, - source: dep.dependentTableId, - target: dep.tableId, - sourceHandle: `${TOP_SOURCE_HANDLE_ID_PREFIX}${dep.dependentTableId}`, - targetHandle: `${TARGET_DEP_PREFIX}${targetDepIndexes[dep.tableId]++}_${dep.tableId}`, - type: 'dependency-edge', - data: { dependency: dep }, - hidden: !showDBViews, - }) - ), - ]); + setEdges((prevEdges) => { + // Preserve temporary edge if it exists + const tempEdge = prevEdges.find( + (e) => e.id === 'temp-relationship-edge' + ); + + return [ + ...relationships.map( + (relationship): RelationshipEdgeType => ({ + id: relationship.id, + source: relationship.sourceTableId, + target: relationship.targetTableId, + sourceHandle: `${LEFT_HANDLE_ID_PREFIX}${relationship.sourceFieldId}`, + targetHandle: `${TARGET_ID_PREFIX}${targetIndexes[`${relationship.targetTableId}${relationship.targetFieldId}`]++}_${relationship.targetFieldId}`, + type: 'relationship-edge', + data: { relationship }, + }) + ), + ...dependencies.map( + (dep): DependencyEdgeType => ({ + id: dep.id, + source: dep.dependentTableId, + target: dep.tableId, + sourceHandle: `${TOP_SOURCE_HANDLE_ID_PREFIX}${dep.dependentTableId}`, + targetHandle: `${TARGET_DEP_PREFIX}${targetDepIndexes[dep.tableId]++}_${dep.tableId}`, + type: 'dependency-edge', + data: { dependency: dep }, + hidden: !showDBViews, + }) + ), + ...(tempEdge ? [tempEdge] : []), + ]; + }); }, [relationships, dependencies, setEdges, showDBViews]); useEffect(() => { @@ -464,6 +560,9 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, showDBViews, forceShow: shouldForceShowTable(table.id), + onStartRelationship: handleStartRelationship, + pendingRelationshipSource, + fieldSelectionDialog, }); // Check if table uses the highlighted custom type @@ -515,6 +614,9 @@ export const Canvas: React.FC = ({ initialTables }) => { filterLoading, showDBViews, shouldForceShowTable, + pendingRelationshipSource, + fieldSelectionDialog, + handleStartRelationship, ]); const prevFilter = useRef(undefined); @@ -622,6 +724,52 @@ export const Canvas: React.FC = ({ initialTables }) => { return; } + // Check if this connection is from right-click "Add Relationship" + if ( + params.sourceHandle?.startsWith?.( + TABLE_RELATIONSHIP_HANDLE_ID_PREFIX + ) + ) { + const sourceTableId = params.source; + const targetTableId = params.target; + + console.log( + '[Canvas] Table-level relationship connection detected:', + { + sourceTableId, + targetTableId, + sourceHandle: params.sourceHandle, + targetHandle: params.targetHandle, + params, + } + ); + + // Close filter when showing relationship dialog + if (showFilter) { + setShowFilter(false); + } + + // Show field selection dialog instead of auto-creating + // Ensure we don't have multiple dialogs + setFieldSelectionDialog((prev) => { + if (prev) { + console.warn( + '[Canvas] Dialog already open, replacing with new one' + ); + } + console.log('[Canvas] Setting field selection dialog:', { + sourceTableId, + targetTableId, + }); + return { + sourceTableId, + targetTableId, + }; + }); + + return; + } + const sourceTableId = params.source; const targetTableId = params.target; const sourceFieldId = params.sourceHandle?.split('_')?.pop() ?? ''; @@ -656,9 +804,72 @@ export const Canvas: React.FC = ({ initialTables }) => { targetFieldId, }); }, - [createRelationship, createDependency, getField, toast, databaseType] + [ + createRelationship, + createDependency, + getField, + toast, + databaseType, + showFilter, + setShowFilter, + ] ); + const onConnectStart = useCallback((_event: unknown, params: unknown) => { + console.log('[Canvas] onConnectStart:', params); + setIsConnecting(true); + }, []); + + const onConnectEnd = useCallback( + (_event: unknown) => { + console.log('[Canvas] onConnectEnd:', _event); + setIsConnecting(false); + + // Clean up any lingering React Flow connection edges + setTimeout(() => { + setEdges((edges) => + edges.filter((e) => !e.id.includes('reactflow__edge')) + ); + }, 50); + }, + [setEdges] + ); + + // Handle ESC key to cancel connection during drag or pending relationship + useEffect(() => { + if (!isConnecting && !pendingRelationshipSource) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + if (pendingRelationshipSource) { + setPendingRelationshipSource(null); + // Clean up temporary edge + setEdges((edges) => + edges.filter( + (e) => e.id !== 'temp-pending-relationship' + ) + ); + toast({ + title: 'Cancelled', + description: 'Relationship creation cancelled', + }); + } + setIsConnecting(false); + + // Simulate releasing the mouse to cancel the connection + const event = new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + } + }; + + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [isConnecting, pendingRelationshipSource, toast, setEdges]); + const onEdgesChangeHandler: OnEdgesChange = useCallback( (changes) => { let changesToApply = changes; @@ -1250,7 +1461,7 @@ export const Canvas: React.FC = ({ initialTables }) => { = ({ initialTables }) => { maxZoom={5} minZoom={0.1} onConnect={onConnectHandler} + onConnectStart={onConnectStart} + onConnectEnd={onConnectEnd} + onNodeClick={(_event, node) => { + // Handle pending relationship creation + if ( + pendingRelationshipSource && + node.type === 'table' + ) { + console.log( + '[Canvas] Table clicked while pending relationship:', + { + source: pendingRelationshipSource, + target: node.id, + } + ); + + if (pendingRelationshipSource !== node.id) { + // Close filter when opening the relationship dialog + if (showFilter) { + setShowFilter(false); + } + + setFieldSelectionDialog({ + sourceTableId: pendingRelationshipSource, + targetTableId: node.id, + }); + } + + // Clean up temporary edge + setEdges((edges) => + edges.filter( + (e) => e.id !== 'temp-pending-relationship' + ) + ); + setPendingRelationshipSource(null); + } + }} + onNodeMouseEnter={(_event, node) => { + // Update temporary edge to point to hovered node + if ( + pendingRelationshipSource && + node.type === 'table' + ) { + setEdges((edges) => + edges.map((edge) => + edge.id === 'temp-pending-relationship' + ? { ...edge, target: node.id } + : edge + ) + ); + } + }} + onNodeMouseLeave={() => { + // Reset temporary edge when leaving a node + if (pendingRelationshipSource) { + setEdges((edges) => + edges.map((edge) => + edge.id === 'temp-pending-relationship' + ? { + ...edge, + target: pendingRelationshipSource, + } + : edge + ) + ); + } + }} proOptions={{ hideAttribution: true, }} @@ -1268,6 +1546,10 @@ export const Canvas: React.FC = ({ initialTables }) => { animated: false, type: 'relationship-edge', }} + connectionLineStyle={{ + stroke: '#3b82f6', + strokeWidth: 2, + }} panOnScroll={scrollAction === 'pan'} snapToGrid={shiftPressed || snapToGridEnabled} snapGrid={[20, 20]} @@ -1454,6 +1736,20 @@ export const Canvas: React.FC = ({ initialTables }) => { setShowFilter(false)} /> ) : null} + {/* Render overlay outside ReactFlow but inside canvas container to ensure proper z-index */} + {fieldSelectionDialog && ( +
+ setFieldSelectionDialog(null)} + /> +
+ )}
diff --git a/src/pages/editor-page/canvas/select-relationship-fields-overlay/select-relationship-fields-overlay.tsx b/src/pages/editor-page/canvas/select-relationship-fields-overlay/select-relationship-fields-overlay.tsx new file mode 100644 index 00000000..1e1e7748 --- /dev/null +++ b/src/pages/editor-page/canvas/select-relationship-fields-overlay/select-relationship-fields-overlay.tsx @@ -0,0 +1,425 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button } from '@/components/button/button'; +import { useChartDB } from '@/hooks/use-chartdb'; +import type { SelectBoxOption } from '@/components/select-box/select-box'; +import { SelectBox } from '@/components/select-box/select-box'; +import { useReactFlow } from '@xyflow/react'; +import { areFieldTypesCompatible } from '@/lib/data/data-types/data-types'; +import { useLayout } from '@/hooks/use-layout'; +import { X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { generateId } from '@/lib/utils'; +import type { DBField } from '@/lib/domain/db-field'; + +export interface SelectRelationshipFieldsOverlayProps { + sourceTableId: string; + targetTableId: string; + onClose: () => void; +} + +export const SelectRelationshipFieldsOverlay: React.FC< + SelectRelationshipFieldsOverlayProps +> = ({ sourceTableId, targetTableId, onClose }) => { + const { getTable, createRelationship, databaseType, addField } = + useChartDB(); + const { setEdges } = useReactFlow(); + const { openRelationshipFromSidebar } = useLayout(); + const [targetFieldId, setTargetFieldId] = useState(); + const [errorMessage, setErrorMessage] = useState(''); + const [isVisible, setIsVisible] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [selectOpen, setSelectOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + // Create a temporary edge to show the relationship line during field selection + useEffect(() => { + const tempEdgeId = 'temp-relationship-edge'; + + // Use requestAnimationFrame for better timing + const rafId = requestAnimationFrame(() => { + setEdges((edges) => { + // Remove any existing temp edge and any React Flow connection edges + const filteredEdges = edges.filter( + (e) => + e.id !== tempEdgeId && !e.id.includes('reactflow__edge') + ); + + return [ + ...filteredEdges, + { + id: tempEdgeId, + source: sourceTableId, + target: targetTableId, + type: 'default', + style: { + stroke: '#3b82f6', + strokeWidth: 2, + strokeDasharray: '5 5', + }, + animated: true, + }, + ]; + }); + }); + + // Remove temporary edge when component unmounts + return () => { + cancelAnimationFrame(rafId); + setEdges((edges) => edges.filter((e) => e.id !== tempEdgeId)); + }; + }, [sourceTableId, targetTableId]); // eslint-disable-line react-hooks/exhaustive-deps + + const sourceTable = useMemo( + () => getTable(sourceTableId), + [sourceTableId, getTable] + ); + const targetTable = useMemo( + () => getTable(targetTableId), + [targetTableId, getTable] + ); + + // Get the PK field from source table + const sourcePKField = useMemo(() => { + if (!sourceTable) return null; + return ( + sourceTable.fields.find((f) => f.primaryKey) || + sourceTable.fields[0] + ); + }, [sourceTable]); + + // Get compatible target fields (FK columns) + const targetFieldOptions = useMemo(() => { + if (!targetTable || !sourcePKField) return []; + + const compatibleFields = targetTable.fields + .filter((field) => + areFieldTypesCompatible( + sourcePKField.type, + field.type, + databaseType + ) + ) + .map( + (field) => + ({ + label: field.name, + value: field.id, + description: `(${field.type.name})`, + }) as SelectBoxOption + ); + + // Add option to create a new field if user typed a custom name + if ( + searchTerm && + !compatibleFields.find( + (f) => f.label.toLowerCase() === searchTerm.toLowerCase() + ) + ) { + compatibleFields.push({ + label: `Create "${searchTerm}"`, + value: 'CREATE_NEW', + description: `(${sourcePKField.type.name})`, + }); + } + + return compatibleFields; + }, [targetTable, sourcePKField, databaseType, searchTerm]); + + // Auto-select first compatible field OR pre-populate suggested name (only once on mount) + useEffect(() => { + if (targetFieldOptions.length > 0 && !targetFieldId) { + setTargetFieldId(targetFieldOptions[0].value as string); + } else if ( + targetFieldOptions.length === 0 && + !searchTerm && + sourceTable && + sourcePKField + ) { + // No compatible fields - suggest a field name based on source table + PK field + const suggestedName = + sourcePKField.name.toLowerCase() === 'id' + ? `${sourceTable.name}_${sourcePKField.name}` + : sourcePKField.name; + setSearchTerm(suggestedName); + } + }, [targetFieldOptions.length, sourceTable, sourcePKField]); // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-open the select immediately and trigger animation + useEffect(() => { + // Open select immediately + setSelectOpen(true); + + // Trigger animation on next frame for smooth transition + const rafId = requestAnimationFrame(() => { + setIsVisible(true); + }); + + return () => cancelAnimationFrame(rafId); + }, []); + + // Store the initial position permanently - calculate only once on mount + const [fixedPosition] = useState(() => { + // Always position at the same place in the viewport: left side, bottom area + return { + left: '20px', + bottom: '80px', + transform: 'translate(0, 0)', + }; + }); + + // Apply drag offset to the fixed position + const position = useMemo(() => { + if (dragOffset.x === 0 && dragOffset.y === 0) { + return fixedPosition; + } + + const leftValue = parseFloat(fixedPosition.left) + dragOffset.x; + const bottomValue = parseFloat(fixedPosition.bottom) - dragOffset.y; // Subtract because bottom increases upward + + return { + left: `${leftValue}px`, + bottom: `${bottomValue}px`, + transform: fixedPosition.transform, + }; + }, [fixedPosition, dragOffset]); + + // Handle dragging + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only start drag if clicking on the header + const target = e.target as HTMLElement; + if (!target.closest('[data-drag-handle]')) { + return; + } + + setIsDragging(true); + e.preventDefault(); + }, + [] + ); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + setDragOffset((prev) => ({ + x: prev.x + e.movementX, + y: prev.y + e.movementY, + })); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging]); + + const handleCreate = useCallback(async () => { + if (!sourcePKField) return; + + try { + let finalTargetFieldId = targetFieldId; + + // If user selected "CREATE_NEW", create the field first + if (targetFieldId === 'CREATE_NEW' && searchTerm) { + const newField: DBField = { + id: generateId(), + name: searchTerm, + type: sourcePKField.type, + unique: false, + nullable: true, + primaryKey: false, + createdAt: Date.now(), + }; + + await addField(targetTableId, newField); + finalTargetFieldId = newField.id; + } + + if (!finalTargetFieldId) return; + + const relationship = await createRelationship({ + sourceTableId, + sourceFieldId: sourcePKField.id, + targetTableId, + targetFieldId: finalTargetFieldId, + }); + + setEdges((edges) => + edges.map((edge) => + edge.id === relationship.id + ? { ...edge, selected: true } + : { ...edge, selected: false } + ) + ); + + openRelationshipFromSidebar(relationship.id); + onClose(); + } catch (error) { + console.error(error); + setErrorMessage('Failed to create relationship'); + } + }, [ + sourcePKField, + targetFieldId, + searchTerm, + sourceTableId, + targetTableId, + createRelationship, + addField, + setEdges, + openRelationshipFromSidebar, + onClose, + ]); + + // Handle ESC key to cancel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + if (!sourceTable || !targetTable || !sourcePKField) { + return null; + } + + return ( +
e.stopPropagation()} + onMouseDown={handleMouseDown} + > + {/* Header - draggable */} +
+
+ Create Relationship +
+ +
+ + {/* Content */} +
+
+ {/* PK Column (Source) */} +
+ +
+ {sourcePKField.name} +
+
+ {sourceTable.name} +
+
+ + {/* Arrow indicator */} +
+ + + +
+ + {/* FK Column (Target) */} +
+ + { + setTargetFieldId(value as string); + }} + emptyPlaceholder="No compatible fields" + onSearchChange={setSearchTerm} + open={selectOpen} + onOpenChange={setSelectOpen} + /> +
+ {targetTable.name} +
+
+
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {targetFieldOptions.length === 0 && ( +
+ No compatible fields found in target table +
+ )} +
+ + {/* Footer */} +
+ +
+
+ ); +}; diff --git a/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx b/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx index 3343b06f..86804f52 100644 --- a/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx @@ -17,11 +17,12 @@ import { useCanvas } from '@/hooks/use-canvas'; export interface TableNodeContextMenuProps { table: DBTable; + onStartRelationshipCreation?: () => void; } export const TableNodeContextMenu: React.FC< React.PropsWithChildren -> = ({ children, table }) => { +> = ({ children, table, onStartRelationshipCreation }) => { const { removeTable, readonly, createTable } = useChartDB(); const { closeAllTablesInSidebar } = useLayout(); const { t } = useTranslation(); @@ -53,10 +54,30 @@ export const TableNodeContextMenu: React.FC< }, [removeTable, table.id]); const addRelationshipHandler = useCallback(() => { - openCreateRelationshipDialog({ - sourceTableId: table.id, + console.log('[TableNodeContextMenu] Add relationship clicked', { + tableId: table.id, + tableName: table.name, + hasStartHandler: !!onStartRelationshipCreation, }); - }, [openCreateRelationshipDialog, table.id]); + + // Use the programmatic handle drag if available, otherwise fall back to dialog + if (onStartRelationshipCreation) { + console.log( + '[TableNodeContextMenu] Calling onStartRelationshipCreation' + ); + onStartRelationshipCreation(); + } else { + console.log('[TableNodeContextMenu] Falling back to dialog'); + openCreateRelationshipDialog({ + sourceTableId: table.id, + }); + } + }, [ + onStartRelationshipCreation, + openCreateRelationshipDialog, + table.id, + table.name, + ]); if (!isDesktop || readonly) { return <>{children}; diff --git a/src/pages/editor-page/canvas/table-node/table-node.tsx b/src/pages/editor-page/canvas/table-node/table-node.tsx index dd658212..f59c611c 100644 --- a/src/pages/editor-page/canvas/table-node/table-node.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node.tsx @@ -6,7 +6,13 @@ import React, { useEffect, } from 'react'; import type { NodeProps, Node } from '@xyflow/react'; -import { NodeResizer, useConnection, useStore } from '@xyflow/react'; +import { + NodeResizer, + useConnection, + useStore, + Handle, + Position, +} from '@xyflow/react'; import { Button } from '@/components/button/button'; import { ChevronsLeftRight, @@ -47,6 +53,9 @@ import { TableNodeStatus } from './table-node-status/table-node-status'; import { TableEditMode } from './table-edit-mode/table-edit-mode'; import { useCanvas } from '@/hooks/use-canvas'; +export const TABLE_RELATIONSHIP_HANDLE_ID_PREFIX = 'table_rel_'; +export const TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX = 'table_rel_target_'; + export type TableNodeType = Node< { table: DBTable; @@ -54,6 +63,11 @@ export type TableNodeType = Node< highlightOverlappingTables?: boolean; hasHighlightedCustomType?: boolean; highlightTable?: boolean; + onStartRelationship?: (sourceTableId: string) => void; + isPendingRelationshipTarget?: boolean; + isDialogSource?: boolean; + isDialogTarget?: boolean; + isPendingRelationshipSource?: boolean; }, 'table' >; @@ -69,6 +83,11 @@ export const TableNode: React.FC> = React.memo( highlightOverlappingTables, hasHighlightedCustomType, highlightTable, + onStartRelationship, + isPendingRelationshipTarget, + isDialogSource, + isDialogTarget, + isPendingRelationshipSource, }, }) => { const { updateTable, relationships, readonly } = useChartDB(); @@ -105,6 +124,17 @@ export const TableNode: React.FC> = React.memo( return connection.inProgress && connection.fromNode.id !== table.id; }, [connection, table.id, isHovering]); + // Check if this is a target for table-level relationship (right-click flow) + const isTableRelationshipTarget = useMemo(() => { + return ( + connection.inProgress && + connection.fromNode.id !== table.id && + connection.fromHandle.id?.startsWith( + TABLE_RELATIONSHIP_HANDLE_ID_PREFIX + ) + ); + }, [connection, table.id]); + const { getTableNewName, getTableNewColor, @@ -318,9 +348,15 @@ export const TableNode: React.FC> = React.memo( () => cn( 'flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-sm transition-transform duration-300', - selected || isTarget - ? 'border-pink-600' - : 'border-slate-500 dark:border-slate-700', + // Highlight both source and target in blue when dialog is open + isDialogSource || isDialogTarget + ? 'border-blue-600 ring-2 ring-blue-600/20' + : // Use blue border for pending relationship targets, pink for normal selection + isPendingRelationshipTarget && isHovering + ? 'border-blue-600' + : selected || isTarget + ? 'border-pink-600' + : 'border-slate-500 dark:border-slate-700', isOverlapping ? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-blue-500 ring-offset-2' : '', @@ -363,6 +399,10 @@ export const TableNode: React.FC> = React.memo( isDiffTableRemoved, isTarget, editTableMode, + isPendingRelationshipTarget, + isHovering, + isDialogSource, + isDialogTarget, ] ); @@ -384,8 +424,85 @@ export const TableNode: React.FC> = React.memo( setEditTableModeTable(null); }, [setEditTableModeTable]); + const startRelationshipCreation = useCallback(() => { + console.log('[TableNode] startRelationshipCreation called', { + tableId: table.id, + tableName: table.name, + readonly, + }); + + if (readonly) { + console.log('[TableNode] Readonly mode, aborting'); + return; + } + + // Check if we have a direct callback from canvas to open the dialog + if (onStartRelationship) { + console.log( + '[TableNode] Using direct callback to start relationship' + ); + onStartRelationship(table.id); + return; + } + + // Fallback: Try to simulate the drag (keeping old implementation as fallback) + const handleId = `${TABLE_RELATIONSHIP_HANDLE_ID_PREFIX}${table.id}`; + console.log( + '[TableNode] Falling back to drag simulation for handle:', + handleId + ); + + const handle = document.querySelector( + `[data-handleid="${handleId}"]` + ) as HTMLElement; + + if (!handle) { + console.error( + '[TableNode] Could not find relationship handle', + { + tableId: table.id, + handleId, + } + ); + return; + } + + const rect = handle.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // Simplified event dispatch - directly trigger mousedown on handle + const mouseDownEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window, + clientX: centerX, + clientY: centerY, + button: 0, + buttons: 1, + }); + + handle.dispatchEvent(mouseDownEvent); + + // Small movement to start drag + setTimeout(() => { + const mouseMoveEvent = new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + view: window, + clientX: centerX + 10, + clientY: centerY + 10, + buttons: 1, + }); + document.dispatchEvent(mouseMoveEvent); + }, 10); + }, [readonly, table.id, table.name, onStartRelationship]); + return ( - + {editTableMode ? ( > = React.memo( table={table} focused={focused} /> + {/* Hidden handles for right-click "Add Relationship" functionality */} + + {/* Target handle for receiving table-level connections */} + > = React.memo( {visibleFields.map((field: DBField) => (