mirror of
https://github.com/chartdb/chartdb.git
synced 2025-10-23 07:11:56 +00:00
fix: fast add relationship by right-click from canvas
This commit is contained in:
committed by
Guy Ben-Aharon
parent
498655e7b7
commit
cc97453d43
@@ -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"
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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}</>;
|
||||
|
@@ -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)}
|
||||
|
Reference in New Issue
Block a user