mirror of
https://github.com/chartdb/chartdb.git
synced 2025-10-24 08:33:44 +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;
|
footerButtons?: React.ReactNode;
|
||||||
commandOnMouseDown?: (e: React.MouseEvent) => void;
|
commandOnMouseDown?: (e: React.MouseEvent) => void;
|
||||||
commandOnClick?: (e: React.MouseEvent) => void;
|
commandOnClick?: (e: React.MouseEvent) => void;
|
||||||
|
onSearchChange?: (search: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||||
@@ -87,6 +88,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
|||||||
footerButtons,
|
footerButtons,
|
||||||
commandOnMouseDown,
|
commandOnMouseDown,
|
||||||
commandOnClick,
|
commandOnClick,
|
||||||
|
onSearchChange,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -404,7 +406,10 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onValueChange={(e) => setSearchTerm(e)}
|
onValueChange={(e) => {
|
||||||
|
setSearchTerm(e);
|
||||||
|
onSearchChange?.(e);
|
||||||
|
}}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
placeholder={inputPlaceholder ?? 'Search...'}
|
placeholder={inputPlaceholder ?? 'Search...'}
|
||||||
className="h-9"
|
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 { defaultSchemas } from '@/lib/data/default-schemas';
|
||||||
import { useDiff } from '@/context/diff-context/use-diff';
|
import { useDiff } from '@/context/diff-context/use-diff';
|
||||||
import { useClickAway } from 'react-use';
|
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 HIGHLIGHTED_EDGE_Z_INDEX = 1;
|
||||||
const DEFAULT_EDGE_Z_INDEX = 0;
|
const DEFAULT_EDGE_Z_INDEX = 0;
|
||||||
@@ -123,12 +125,21 @@ const tableToTableNode = (
|
|||||||
filterLoading,
|
filterLoading,
|
||||||
showDBViews,
|
showDBViews,
|
||||||
forceShow,
|
forceShow,
|
||||||
|
onStartRelationship,
|
||||||
|
pendingRelationshipSource,
|
||||||
|
fieldSelectionDialog,
|
||||||
}: {
|
}: {
|
||||||
filter?: DiagramFilter;
|
filter?: DiagramFilter;
|
||||||
databaseType: DatabaseType;
|
databaseType: DatabaseType;
|
||||||
filterLoading: boolean;
|
filterLoading: boolean;
|
||||||
showDBViews?: boolean;
|
showDBViews?: boolean;
|
||||||
forceShow?: boolean;
|
forceShow?: boolean;
|
||||||
|
onStartRelationship?: (sourceTableId: string) => void;
|
||||||
|
pendingRelationshipSource?: string | null;
|
||||||
|
fieldSelectionDialog?: {
|
||||||
|
sourceTableId: string;
|
||||||
|
targetTableId: string;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
): TableNodeType => {
|
): TableNodeType => {
|
||||||
// Always use absolute position for now
|
// Always use absolute position for now
|
||||||
@@ -149,6 +160,13 @@ const tableToTableNode = (
|
|||||||
(!showDBViews && table.isView);
|
(!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 {
|
return {
|
||||||
id: table.id,
|
id: table.id,
|
||||||
type: 'table',
|
type: 'table',
|
||||||
@@ -156,6 +174,13 @@ const tableToTableNode = (
|
|||||||
data: {
|
data: {
|
||||||
table,
|
table,
|
||||||
isOverlapping: false,
|
isOverlapping: false,
|
||||||
|
onStartRelationship,
|
||||||
|
isPendingRelationshipTarget:
|
||||||
|
pendingRelationshipSource !== null &&
|
||||||
|
pendingRelationshipSource !== table.id,
|
||||||
|
isDialogSource,
|
||||||
|
isDialogTarget,
|
||||||
|
isPendingRelationshipSource,
|
||||||
},
|
},
|
||||||
width: table.width ?? MIN_TABLE_SIZE,
|
width: table.width ?? MIN_TABLE_SIZE,
|
||||||
hidden,
|
hidden,
|
||||||
@@ -253,6 +278,20 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
const { filter, loading: filterLoading } = useDiagramFilter();
|
const { filter, loading: filterLoading } = useDiagramFilter();
|
||||||
const { checkIfNewTable } = useDiff();
|
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(
|
const shouldForceShowTable = useCallback(
|
||||||
(tableId: string) => {
|
(tableId: string) => {
|
||||||
return checkIfNewTable({ tableId });
|
return checkIfNewTable({ tableId });
|
||||||
@@ -262,6 +301,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
|
|
||||||
const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true);
|
const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true);
|
||||||
|
|
||||||
|
// Initialize nodes without the callback first
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<NodeType>(
|
const [nodes, setNodes, onNodesChange] = useNodesState<NodeType>(
|
||||||
initialTables.map((table) =>
|
initialTables.map((table) =>
|
||||||
tableToTableNode(table, {
|
tableToTableNode(table, {
|
||||||
@@ -270,12 +310,58 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
filterLoading,
|
filterLoading,
|
||||||
showDBViews,
|
showDBViews,
|
||||||
forceShow: shouldForceShowTable(table.id),
|
forceShow: shouldForceShowTable(table.id),
|
||||||
|
onStartRelationship: undefined, // Will be set later
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const [edges, setEdges, onEdgesChange] =
|
const [edges, setEdges, onEdgesChange] =
|
||||||
useEdgesState<EdgeType>(initialEdges);
|
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);
|
const [snapToGridEnabled, setSnapToGridEnabled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -290,6 +376,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
filterLoading,
|
filterLoading,
|
||||||
showDBViews,
|
showDBViews,
|
||||||
forceShow: shouldForceShowTable(table.id),
|
forceShow: shouldForceShowTable(table.id),
|
||||||
|
onStartRelationship: handleStartRelationship,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (equal(initialNodes, nodes)) {
|
if (equal(initialNodes, nodes)) {
|
||||||
@@ -303,6 +390,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
filterLoading,
|
filterLoading,
|
||||||
showDBViews,
|
showDBViews,
|
||||||
shouldForceShowTable,
|
shouldForceShowTable,
|
||||||
|
handleStartRelationship,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -336,31 +424,39 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
{} as Record<string, number>
|
{} as Record<string, number>
|
||||||
);
|
);
|
||||||
|
|
||||||
setEdges([
|
setEdges((prevEdges) => {
|
||||||
...relationships.map(
|
// Preserve temporary edge if it exists
|
||||||
(relationship): RelationshipEdgeType => ({
|
const tempEdge = prevEdges.find(
|
||||||
id: relationship.id,
|
(e) => e.id === 'temp-relationship-edge'
|
||||||
source: relationship.sourceTableId,
|
);
|
||||||
target: relationship.targetTableId,
|
|
||||||
sourceHandle: `${LEFT_HANDLE_ID_PREFIX}${relationship.sourceFieldId}`,
|
return [
|
||||||
targetHandle: `${TARGET_ID_PREFIX}${targetIndexes[`${relationship.targetTableId}${relationship.targetFieldId}`]++}_${relationship.targetFieldId}`,
|
...relationships.map(
|
||||||
type: 'relationship-edge',
|
(relationship): RelationshipEdgeType => ({
|
||||||
data: { relationship },
|
id: relationship.id,
|
||||||
})
|
source: relationship.sourceTableId,
|
||||||
),
|
target: relationship.targetTableId,
|
||||||
...dependencies.map(
|
sourceHandle: `${LEFT_HANDLE_ID_PREFIX}${relationship.sourceFieldId}`,
|
||||||
(dep): DependencyEdgeType => ({
|
targetHandle: `${TARGET_ID_PREFIX}${targetIndexes[`${relationship.targetTableId}${relationship.targetFieldId}`]++}_${relationship.targetFieldId}`,
|
||||||
id: dep.id,
|
type: 'relationship-edge',
|
||||||
source: dep.dependentTableId,
|
data: { relationship },
|
||||||
target: dep.tableId,
|
})
|
||||||
sourceHandle: `${TOP_SOURCE_HANDLE_ID_PREFIX}${dep.dependentTableId}`,
|
),
|
||||||
targetHandle: `${TARGET_DEP_PREFIX}${targetDepIndexes[dep.tableId]++}_${dep.tableId}`,
|
...dependencies.map(
|
||||||
type: 'dependency-edge',
|
(dep): DependencyEdgeType => ({
|
||||||
data: { dependency: dep },
|
id: dep.id,
|
||||||
hidden: !showDBViews,
|
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]);
|
}, [relationships, dependencies, setEdges, showDBViews]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -464,6 +560,9 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
filterLoading,
|
filterLoading,
|
||||||
showDBViews,
|
showDBViews,
|
||||||
forceShow: shouldForceShowTable(table.id),
|
forceShow: shouldForceShowTable(table.id),
|
||||||
|
onStartRelationship: handleStartRelationship,
|
||||||
|
pendingRelationshipSource,
|
||||||
|
fieldSelectionDialog,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if table uses the highlighted custom type
|
// Check if table uses the highlighted custom type
|
||||||
@@ -515,6 +614,9 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
filterLoading,
|
filterLoading,
|
||||||
showDBViews,
|
showDBViews,
|
||||||
shouldForceShowTable,
|
shouldForceShowTable,
|
||||||
|
pendingRelationshipSource,
|
||||||
|
fieldSelectionDialog,
|
||||||
|
handleStartRelationship,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const prevFilter = useRef<DiagramFilter | undefined>(undefined);
|
const prevFilter = useRef<DiagramFilter | undefined>(undefined);
|
||||||
@@ -622,6 +724,52 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
return;
|
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 sourceTableId = params.source;
|
||||||
const targetTableId = params.target;
|
const targetTableId = params.target;
|
||||||
const sourceFieldId = params.sourceHandle?.split('_')?.pop() ?? '';
|
const sourceFieldId = params.sourceHandle?.split('_')?.pop() ?? '';
|
||||||
@@ -656,9 +804,72 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
targetFieldId,
|
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(
|
const onEdgesChangeHandler: OnEdgesChange<EdgeType> = useCallback(
|
||||||
(changes) => {
|
(changes) => {
|
||||||
let changesToApply = changes;
|
let changesToApply = changes;
|
||||||
@@ -1250,7 +1461,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
<ReactFlow
|
<ReactFlow
|
||||||
onlyRenderVisibleElements
|
onlyRenderVisibleElements
|
||||||
colorMode={effectiveTheme}
|
colorMode={effectiveTheme}
|
||||||
className="canvas-cursor-default nodes-animated"
|
className={`${pendingRelationshipSource ? 'cursor-crosshair' : ''} nodes-animated`}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChangeHandler}
|
onNodesChange={onNodesChangeHandler}
|
||||||
@@ -1258,6 +1469,73 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
maxZoom={5}
|
maxZoom={5}
|
||||||
minZoom={0.1}
|
minZoom={0.1}
|
||||||
onConnect={onConnectHandler}
|
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={{
|
proOptions={{
|
||||||
hideAttribution: true,
|
hideAttribution: true,
|
||||||
}}
|
}}
|
||||||
@@ -1268,6 +1546,10 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
animated: false,
|
animated: false,
|
||||||
type: 'relationship-edge',
|
type: 'relationship-edge',
|
||||||
}}
|
}}
|
||||||
|
connectionLineStyle={{
|
||||||
|
stroke: '#3b82f6',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}}
|
||||||
panOnScroll={scrollAction === 'pan'}
|
panOnScroll={scrollAction === 'pan'}
|
||||||
snapToGrid={shiftPressed || snapToGridEnabled}
|
snapToGrid={shiftPressed || snapToGridEnabled}
|
||||||
snapGrid={[20, 20]}
|
snapGrid={[20, 20]}
|
||||||
@@ -1454,6 +1736,20 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
<CanvasFilter onClose={() => setShowFilter(false)} />
|
<CanvasFilter onClose={() => setShowFilter(false)} />
|
||||||
) : null}
|
) : null}
|
||||||
</ReactFlow>
|
</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 />
|
<MarkerDefinitions />
|
||||||
</div>
|
</div>
|
||||||
</CanvasContextMenu>
|
</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 {
|
export interface TableNodeContextMenuProps {
|
||||||
table: DBTable;
|
table: DBTable;
|
||||||
|
onStartRelationshipCreation?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableNodeContextMenu: React.FC<
|
export const TableNodeContextMenu: React.FC<
|
||||||
React.PropsWithChildren<TableNodeContextMenuProps>
|
React.PropsWithChildren<TableNodeContextMenuProps>
|
||||||
> = ({ children, table }) => {
|
> = ({ children, table, onStartRelationshipCreation }) => {
|
||||||
const { removeTable, readonly, createTable } = useChartDB();
|
const { removeTable, readonly, createTable } = useChartDB();
|
||||||
const { closeAllTablesInSidebar } = useLayout();
|
const { closeAllTablesInSidebar } = useLayout();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -53,10 +54,30 @@ export const TableNodeContextMenu: React.FC<
|
|||||||
}, [removeTable, table.id]);
|
}, [removeTable, table.id]);
|
||||||
|
|
||||||
const addRelationshipHandler = useCallback(() => {
|
const addRelationshipHandler = useCallback(() => {
|
||||||
openCreateRelationshipDialog({
|
console.log('[TableNodeContextMenu] Add relationship clicked', {
|
||||||
sourceTableId: table.id,
|
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) {
|
if (!isDesktop || readonly) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { NodeProps, Node } from '@xyflow/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 { Button } from '@/components/button/button';
|
||||||
import {
|
import {
|
||||||
ChevronsLeftRight,
|
ChevronsLeftRight,
|
||||||
@@ -47,6 +53,9 @@ import { TableNodeStatus } from './table-node-status/table-node-status';
|
|||||||
import { TableEditMode } from './table-edit-mode/table-edit-mode';
|
import { TableEditMode } from './table-edit-mode/table-edit-mode';
|
||||||
import { useCanvas } from '@/hooks/use-canvas';
|
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<
|
export type TableNodeType = Node<
|
||||||
{
|
{
|
||||||
table: DBTable;
|
table: DBTable;
|
||||||
@@ -54,6 +63,11 @@ export type TableNodeType = Node<
|
|||||||
highlightOverlappingTables?: boolean;
|
highlightOverlappingTables?: boolean;
|
||||||
hasHighlightedCustomType?: boolean;
|
hasHighlightedCustomType?: boolean;
|
||||||
highlightTable?: boolean;
|
highlightTable?: boolean;
|
||||||
|
onStartRelationship?: (sourceTableId: string) => void;
|
||||||
|
isPendingRelationshipTarget?: boolean;
|
||||||
|
isDialogSource?: boolean;
|
||||||
|
isDialogTarget?: boolean;
|
||||||
|
isPendingRelationshipSource?: boolean;
|
||||||
},
|
},
|
||||||
'table'
|
'table'
|
||||||
>;
|
>;
|
||||||
@@ -69,6 +83,11 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
|||||||
highlightOverlappingTables,
|
highlightOverlappingTables,
|
||||||
hasHighlightedCustomType,
|
hasHighlightedCustomType,
|
||||||
highlightTable,
|
highlightTable,
|
||||||
|
onStartRelationship,
|
||||||
|
isPendingRelationshipTarget,
|
||||||
|
isDialogSource,
|
||||||
|
isDialogTarget,
|
||||||
|
isPendingRelationshipSource,
|
||||||
},
|
},
|
||||||
}) => {
|
}) => {
|
||||||
const { updateTable, relationships, readonly } = useChartDB();
|
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;
|
return connection.inProgress && connection.fromNode.id !== table.id;
|
||||||
}, [connection, table.id, isHovering]);
|
}, [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 {
|
const {
|
||||||
getTableNewName,
|
getTableNewName,
|
||||||
getTableNewColor,
|
getTableNewColor,
|
||||||
@@ -318,9 +348,15 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
|||||||
() =>
|
() =>
|
||||||
cn(
|
cn(
|
||||||
'flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-sm transition-transform duration-300',
|
'flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-sm transition-transform duration-300',
|
||||||
selected || isTarget
|
// Highlight both source and target in blue when dialog is open
|
||||||
? 'border-pink-600'
|
isDialogSource || isDialogTarget
|
||||||
: 'border-slate-500 dark:border-slate-700',
|
? '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
|
isOverlapping
|
||||||
? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-blue-500 ring-offset-2'
|
? '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,
|
isDiffTableRemoved,
|
||||||
isTarget,
|
isTarget,
|
||||||
editTableMode,
|
editTableMode,
|
||||||
|
isPendingRelationshipTarget,
|
||||||
|
isHovering,
|
||||||
|
isDialogSource,
|
||||||
|
isDialogTarget,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -384,8 +424,85 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
|||||||
setEditTableModeTable(null);
|
setEditTableModeTable(null);
|
||||||
}, [setEditTableModeTable]);
|
}, [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 (
|
return (
|
||||||
<TableNodeContextMenu table={table}>
|
<TableNodeContextMenu
|
||||||
|
table={table}
|
||||||
|
onStartRelationshipCreation={startRelationshipCreation}
|
||||||
|
>
|
||||||
{editTableMode ? (
|
{editTableMode ? (
|
||||||
<TableEditMode
|
<TableEditMode
|
||||||
table={table}
|
table={table}
|
||||||
@@ -418,6 +535,29 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
|||||||
table={table}
|
table={table}
|
||||||
focused={focused}
|
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
|
<TableNodeStatus
|
||||||
status={
|
status={
|
||||||
isDiffNewTable
|
isDiffNewTable
|
||||||
@@ -541,7 +681,12 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
|
|||||||
{visibleFields.map((field: DBField) => (
|
{visibleFields.map((field: DBField) => (
|
||||||
<TableNodeField
|
<TableNodeField
|
||||||
key={field.id}
|
key={field.id}
|
||||||
focused={focused}
|
focused={
|
||||||
|
focused &&
|
||||||
|
!isPendingRelationshipTarget &&
|
||||||
|
!isDialogSource &&
|
||||||
|
!isPendingRelationshipSource
|
||||||
|
}
|
||||||
tableNodeId={id}
|
tableNodeId={id}
|
||||||
field={field}
|
field={field}
|
||||||
highlighted={highlightedFieldIds.has(field.id)}
|
highlighted={highlightedFieldIds.has(field.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user