fix: fast add relationship by right-click from canvas

This commit is contained in:
johnnyfish
2025-10-06 23:42:11 +03:00
committed by Guy Ben-Aharon
parent 498655e7b7
commit cc97453d43
6 changed files with 1148 additions and 38 deletions

View File

@@ -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<HTMLInputElement, SelectBoxProps>(
@@ -87,6 +88,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
footerButtons,
commandOnMouseDown,
commandOnClick,
onSearchChange,
},
ref
) => {
@@ -404,7 +406,10 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
<div className="relative">
<CommandInput
value={searchTerm}
onValueChange={(e) => setSearchTerm(e)}
onValueChange={(e) => {
setSearchTerm(e);
onSearchChange?.(e);
}}
ref={ref}
placeholder={inputPlaceholder ?? 'Search...'}
className="h-9"

View File

@@ -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<string | undefined>();
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 (
<Dialog
open={open}
onOpenChange={(isOpen) => !isOpen && onClose()}
modal={false}
>
<DialogContent
className="flex max-w-md flex-col overflow-y-auto"
showClose
forceOverlay
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Select Relationship Fields</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-3">
<div className="flex flex-row justify-between gap-4">
{/* PK Column (Source) */}
<div className="flex flex-1 flex-col gap-2">
<div className="text-sm font-semibold">
PK Column
</div>
<div className="flex h-10 items-center rounded-md border border-slate-300 bg-slate-100 px-3 text-sm dark:border-slate-700 dark:bg-slate-800">
{sourcePKField.name}
</div>
</div>
{/* FK Column (Target) */}
<div className="flex flex-1 flex-col gap-2">
<div className="text-sm font-semibold">
FK Column
</div>
<SelectBox
className="flex h-10 w-full"
options={targetFieldOptions}
placeholder="Select field..."
value={targetFieldId}
onChange={(value) =>
setTargetFieldId(value as string)
}
emptyPlaceholder="No compatible fields"
/>
</div>
</div>
{errorMessage && (
<p className="text-sm text-red-700">{errorMessage}</p>
)}
{targetFieldOptions.length === 0 && (
<p className="text-sm text-yellow-700">
No compatible fields found in target table
</p>
)}
</div>
<DialogFooter className="flex !justify-between gap-2">
<DialogClose asChild>
<Button
type="button"
variant="secondary"
onClick={onClose}
>
Cancel
</Button>
</DialogClose>
<Button
disabled={
!targetFieldId || targetFieldOptions.length === 0
}
type="button"
onClick={handleCreate}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -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<CanvasProps> = ({ 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<CanvasProps> = ({ initialTables }) => {
const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true);
// Initialize nodes without the callback first
const [nodes, setNodes, onNodesChange] = useNodesState<NodeType>(
initialTables.map((table) =>
tableToTableNode(table, {
@@ -270,12 +310,58 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
onStartRelationship: undefined, // Will be set later
})
)
);
const [edges, setEdges, onEdgesChange] =
useEdgesState<EdgeType>(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<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
onStartRelationship: handleStartRelationship,
})
);
if (equal(initialNodes, nodes)) {
@@ -303,6 +390,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
shouldForceShowTable,
handleStartRelationship,
]);
useEffect(() => {
@@ -336,31 +424,39 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
{} as Record<string, number>
);
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<CanvasProps> = ({ 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<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
shouldForceShowTable,
pendingRelationshipSource,
fieldSelectionDialog,
handleStartRelationship,
]);
const prevFilter = useRef<DiagramFilter | undefined>(undefined);
@@ -622,6 +724,52 @@ export const Canvas: React.FC<CanvasProps> = ({ 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<CanvasProps> = ({ 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<EdgeType> = useCallback(
(changes) => {
let changesToApply = changes;
@@ -1250,7 +1461,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
<ReactFlow
onlyRenderVisibleElements
colorMode={effectiveTheme}
className="canvas-cursor-default nodes-animated"
className={`${pendingRelationshipSource ? 'cursor-crosshair' : ''} nodes-animated`}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChangeHandler}
@@ -1258,6 +1469,73 @@ export const Canvas: React.FC<CanvasProps> = ({ 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<CanvasProps> = ({ 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<CanvasProps> = ({ initialTables }) => {
<CanvasFilter onClose={() => setShowFilter(false)} />
) : null}
</ReactFlow>
{/* Render overlay outside ReactFlow but inside canvas container to ensure proper z-index */}
{fieldSelectionDialog && (
<div
className="pointer-events-none absolute inset-0"
style={{ zIndex: 100 }}
>
<SelectRelationshipFieldsOverlay
key={`${fieldSelectionDialog.sourceTableId}-${fieldSelectionDialog.targetTableId}`}
sourceTableId={fieldSelectionDialog.sourceTableId}
targetTableId={fieldSelectionDialog.targetTableId}
onClose={() => setFieldSelectionDialog(null)}
/>
</div>
)}
<MarkerDefinitions />
</div>
</CanvasContextMenu>

View File

@@ -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<string | undefined>();
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<string>('');
// 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<HTMLDivElement>) => {
// 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 (
<div
className={cn(
'pointer-events-auto absolute flex cursor-auto flex-col rounded-lg border border-slate-300 bg-white shadow-xl transition-all duration-100 ease-out dark:border-slate-600 dark:bg-slate-800',
{
'scale-100 opacity-100': isVisible,
'scale-95 opacity-0': !isVisible,
}
)}
style={{
left: position.left,
bottom: position.bottom,
transform: position.transform,
minWidth: '380px',
maxWidth: '420px',
userSelect: isDragging ? 'none' : 'auto',
zIndex: 1000, // Higher than React Flow's controls (z-10) and minimap (z-5)
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={handleMouseDown}
>
{/* Header - draggable */}
<div
data-drag-handle
className={cn(
'flex items-center justify-between gap-2 rounded-t-lg border-b bg-blue-500 px-3 py-2 dark:border-slate-600 dark:bg-blue-600',
isDragging ? 'cursor-grabbing' : 'cursor-grab'
)}
>
<div className="text-sm font-semibold text-white">
Create Relationship
</div>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-white hover:bg-blue-600 dark:hover:bg-blue-700"
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
{/* Content */}
<div className="nodrag flex flex-col gap-3 p-3">
<div className="flex flex-row gap-2">
{/* PK Column (Source) */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600 dark:text-slate-300">
From (PK)
</label>
<div className="flex h-9 items-center rounded-md border border-slate-200 bg-slate-50 px-2.5 text-sm font-medium text-slate-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200">
{sourcePKField.name}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{sourceTable.name}
</div>
</div>
{/* Arrow indicator */}
<div className="flex items-center pt-5">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4 text-blue-500 dark:text-blue-400"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
{/* FK Column (Target) */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600 dark:text-slate-300">
To (FK)
</label>
<SelectBox
className="flex h-9 w-full dark:border-slate-200"
popoverClassName="!z-[1001]" // Higher than the dialog's z-index
options={targetFieldOptions}
placeholder="Select field..."
inputPlaceholder="Search or Create..."
value={targetFieldId}
onChange={(value) => {
setTargetFieldId(value as string);
}}
emptyPlaceholder="No compatible fields"
onSearchChange={setSearchTerm}
open={selectOpen}
onOpenChange={setSelectOpen}
/>
<div className="text-xs text-slate-500 dark:text-slate-400">
{targetTable.name}
</div>
</div>
</div>
{errorMessage && (
<div className="rounded-md bg-red-50 p-2 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
{errorMessage}
</div>
)}
{targetFieldOptions.length === 0 && (
<div className="rounded-md bg-yellow-50 p-2 text-xs text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400">
No compatible fields found in target table
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 rounded-b-lg border-t border-slate-200 bg-slate-50 px-3 py-2 dark:border-slate-600 dark:bg-slate-900">
<Button
disabled={!targetFieldId || targetFieldOptions.length === 0}
type="button"
onClick={handleCreate}
className="h-8 bg-blue-500 px-3 text-xs text-white hover:bg-blue-600 dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700"
>
Create
</Button>
</div>
</div>
);
};

View File

@@ -17,11 +17,12 @@ import { useCanvas } from '@/hooks/use-canvas';
export interface TableNodeContextMenuProps {
table: DBTable;
onStartRelationshipCreation?: () => void;
}
export const TableNodeContextMenu: React.FC<
React.PropsWithChildren<TableNodeContextMenuProps>
> = ({ 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}</>;

View File

@@ -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<NodeProps<TableNodeType>> = React.memo(
highlightOverlappingTables,
hasHighlightedCustomType,
highlightTable,
onStartRelationship,
isPendingRelationshipTarget,
isDialogSource,
isDialogTarget,
isPendingRelationshipSource,
},
}) => {
const { updateTable, relationships, readonly } = useChartDB();
@@ -105,6 +124,17 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = 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<NodeProps<TableNodeType>> = 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<NodeProps<TableNodeType>> = React.memo(
isDiffTableRemoved,
isTarget,
editTableMode,
isPendingRelationshipTarget,
isHovering,
isDialogSource,
isDialogTarget,
]
);
@@ -384,8 +424,85 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = 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 (
<TableNodeContextMenu table={table}>
<TableNodeContextMenu
table={table}
onStartRelationshipCreation={startRelationshipCreation}
>
{editTableMode ? (
<TableEditMode
table={table}
@@ -418,6 +535,29 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
table={table}
focused={focused}
/>
{/* Hidden handles for right-click "Add Relationship" functionality */}
<Handle
id={`${TABLE_RELATIONSHIP_HANDLE_ID_PREFIX}${table.id}`}
type="source"
position={Position.Right}
className="!pointer-events-auto !absolute !right-0 !top-1/2 !opacity-0"
style={{ width: '1px', height: '1px' }}
/>
{/* Target handle for receiving table-level connections */}
<Handle
id={`${TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX}${table.id}`}
type="target"
position={Position.Left}
className={
isTableRelationshipTarget
? '!pointer-events-auto !absolute !left-0 !top-0 !z-[100] !h-full !w-full !transform-none !rounded-none !border-none !opacity-0'
: '!pointer-events-none !absolute !left-0 !top-0 !opacity-0'
}
style={{
width: isTableRelationshipTarget ? '100%' : '1px',
height: isTableRelationshipTarget ? '100%' : '1px',
}}
/>
<TableNodeStatus
status={
isDiffNewTable
@@ -541,7 +681,12 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
{visibleFields.map((field: DBField) => (
<TableNodeField
key={field.id}
focused={focused}
focused={
focused &&
!isPendingRelationshipTarget &&
!isDialogSource &&
!isPendingRelationshipSource
}
tableNodeId={id}
field={field}
highlighted={highlightedFieldIds.has(field.id)}