Compare commits

..

4 Commits

Author SHA1 Message Date
Guy Ben-Aharon
619cdc564c chore(main): release 1.17.0 2025-10-16 21:08:33 +03:00
Jonathan Fishner
459698b5d0 fix: add support for parsing default values in DBML (#948) 2025-10-16 21:07:55 +03:00
Guy Ben-Aharon
7ad0e7712d fix: manipulate schema directly from the canvas (#947) 2025-10-16 17:37:20 +03:00
Guy Ben-Aharon
34475add32 feat: create relationships on canvas modal (#946)
* feat: create relationships on canvas modal

* feat: create relationships on canvas modal

* feat: create relationships on canvas modal

* fix

* fix

* fix

* fix
2025-10-13 18:08:28 +03:00
18 changed files with 1056 additions and 748 deletions

View File

@@ -1,5 +1,25 @@
# Changelog
## [1.17.0](https://github.com/chartdb/chartdb/compare/v1.16.0...v1.17.0) (2025-10-16)
### Features
* create relationships on canvas modal ([#946](https://github.com/chartdb/chartdb/issues/946)) ([34475ad](https://github.com/chartdb/chartdb/commit/34475add32f11323589ef092ccf2a8e9152ff272))
### Bug Fixes
* add auto-increment field detection in smart-query import ([#935](https://github.com/chartdb/chartdb/issues/935)) ([57b3b87](https://github.com/chartdb/chartdb/commit/57b3b8777fd0a445abf0ba6603faab612d469d5c))
* add rels export dbml ([#937](https://github.com/chartdb/chartdb/issues/937)) ([c3c646b](https://github.com/chartdb/chartdb/commit/c3c646bf7cbb1328f4b2eb85c9a7e929f0fcd3b9))
* add support for parsing default values in DBML ([#948](https://github.com/chartdb/chartdb/issues/948)) ([459698b](https://github.com/chartdb/chartdb/commit/459698b5d0a1ff23a3719c2e55e4ab2e2384c4fe))
* add timestampz and int as datatypes to postgres ([#940](https://github.com/chartdb/chartdb/issues/940)) ([b15bc94](https://github.com/chartdb/chartdb/commit/b15bc945acb96d7cb3832b3b1b607dfcaef9e5ca))
* auto-enter edit mode when creating new tables from canvas ([#943](https://github.com/chartdb/chartdb/issues/943)) ([bcd8aa9](https://github.com/chartdb/chartdb/commit/bcd8aa9378aa563f40a2b6802cc503be4c882356))
* dbml diff fields types preview ([#934](https://github.com/chartdb/chartdb/issues/934)) ([bb03309](https://github.com/chartdb/chartdb/commit/bb033091b1f64b888822be1423a80f16f5314f6b))
* exit table edit on area click ([#945](https://github.com/chartdb/chartdb/issues/945)) ([38fedce](https://github.com/chartdb/chartdb/commit/38fedcec0c10ea2b3f0b7fc92ca1f5ac9e540389))
* manipulate schema directly from the canvas ([#947](https://github.com/chartdb/chartdb/issues/947)) ([7ad0e77](https://github.com/chartdb/chartdb/commit/7ad0e7712de975a23b2a337dc0a4a7fb4b122bd1))
* prevent text input glitch when editing table field names ([#944](https://github.com/chartdb/chartdb/issues/944)) ([498655e](https://github.com/chartdb/chartdb/commit/498655e7b77e57eaf641ba86263ce1ef60b93e16))
## [1.16.0](https://github.com/chartdb/chartdb/compare/v1.15.1...v1.16.0) (2025-09-24)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "chartdb",
"version": "1.16.0",
"version": "1.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chartdb",
"version": "1.16.0",
"version": "1.17.0",
"dependencies": {
"@ai-sdk/openai": "^0.0.51",
"@dbml/core": "^3.13.9",

View File

@@ -1,7 +1,7 @@
{
"name": "chartdb",
"private": true,
"version": "1.16.0",
"version": "1.17.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -242,6 +242,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
<CommandItem
className="flex items-center"
key={option.value}
value={option.label}
keywords={option.regex ? [option.regex] : undefined}
onSelect={() =>
handleSelect(

View File

@@ -24,6 +24,31 @@ export interface CanvasContext {
fieldId?: string;
} | null>
>;
tempFloatingEdge: {
sourceNodeId: string;
targetNodeId?: string;
} | null;
setTempFloatingEdge: React.Dispatch<
React.SetStateAction<{
sourceNodeId: string;
targetNodeId?: string;
} | null>
>;
startFloatingEdgeCreation: ({
sourceNodeId,
}: {
sourceNodeId: string;
}) => void;
endFloatingEdgeCreation: () => void;
hoveringTableId: string | null;
setHoveringTableId: React.Dispatch<React.SetStateAction<string | null>>;
showCreateRelationshipNode: (params: {
sourceTableId: string;
targetTableId: string;
x: number;
y: number;
}) => void;
hideCreateRelationshipNode: () => void;
}
export const canvasContext = createContext<CanvasContext>({
@@ -35,4 +60,12 @@ export const canvasContext = createContext<CanvasContext>({
showFilter: false,
editTableModeTable: null,
setEditTableModeTable: emptyFn,
tempFloatingEdge: null,
setTempFloatingEdge: emptyFn,
startFloatingEdgeCreation: emptyFn,
endFloatingEdgeCreation: emptyFn,
hoveringTableId: null,
setHoveringTableId: emptyFn,
showCreateRelationshipNode: emptyFn,
hideCreateRelationshipNode: emptyFn,
});

View File

@@ -5,6 +5,7 @@ import React, {
useEffect,
useRef,
} from 'react';
import type { CanvasContext } from './canvas-context';
import { canvasContext } from './canvas-context';
import { useChartDB } from '@/hooks/use-chartdb';
import { adjustTablePositions } from '@/lib/domain/db-table';
@@ -15,6 +16,10 @@ import { createGraph } from '@/lib/graph';
import { useDiagramFilter } from '../diagram-filter-context/use-diagram-filter';
import { filterTable } from '@/lib/domain/diagram-filter/filter';
import { defaultSchemas } from '@/lib/data/default-schemas';
import {
CREATE_RELATIONSHIP_NODE_ID,
type CreateRelationshipNodeType,
} from '@/pages/editor-page/canvas/create-relationship-node/create-relationship-node';
interface CanvasProviderProps {
children: ReactNode;
@@ -30,7 +35,7 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
diagramId,
} = useChartDB();
const { filter, loading: filterLoading } = useDiagramFilter();
const { fitView } = useReactFlow();
const { fitView, screenToFlowPosition, setNodes } = useReactFlow();
const [overlapGraph, setOverlapGraph] =
useState<Graph<string>>(createGraph());
const [editTableModeTable, setEditTableModeTable] = useState<{
@@ -39,6 +44,12 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
} | null>(null);
const [showFilter, setShowFilter] = useState(false);
const [tempFloatingEdge, setTempFloatingEdge] =
useState<CanvasContext['tempFloatingEdge']>(null);
const [hoveringTableId, setHoveringTableId] = useState<string | null>(null);
const diagramIdActiveFilterRef = useRef<string>();
useEffect(() => {
@@ -122,6 +133,66 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
]
);
const startFloatingEdgeCreation: CanvasContext['startFloatingEdgeCreation'] =
useCallback(({ sourceNodeId }) => {
setShowFilter(false);
setTempFloatingEdge({
sourceNodeId,
});
}, []);
const endFloatingEdgeCreation: CanvasContext['endFloatingEdgeCreation'] =
useCallback(() => {
setTempFloatingEdge(null);
}, []);
const hideCreateRelationshipNode: CanvasContext['hideCreateRelationshipNode'] =
useCallback(() => {
setNodes((nds) =>
nds.filter((n) => n.id !== CREATE_RELATIONSHIP_NODE_ID)
);
endFloatingEdgeCreation();
}, [setNodes, endFloatingEdgeCreation]);
const showCreateRelationshipNode: CanvasContext['showCreateRelationshipNode'] =
useCallback(
({ sourceTableId, targetTableId, x, y }) => {
setTempFloatingEdge((edge) =>
edge
? {
...edge,
targetNodeId: targetTableId,
}
: null
);
const cursorPos = screenToFlowPosition({
x,
y,
});
const newNode: CreateRelationshipNodeType = {
id: CREATE_RELATIONSHIP_NODE_ID,
type: 'create-relationship',
position: cursorPos,
data: {
sourceTableId,
targetTableId,
},
draggable: true,
selectable: false,
zIndex: 1000,
};
setNodes((nds) => {
const nodesWithoutOldCreateRelationshipNode = nds.filter(
(n) => n.id !== CREATE_RELATIONSHIP_NODE_ID
);
return [...nodesWithoutOldCreateRelationshipNode, newNode];
});
},
[screenToFlowPosition, setNodes]
);
return (
<canvasContext.Provider
value={{
@@ -133,6 +204,14 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
showFilter,
editTableModeTable,
setEditTableModeTable,
tempFloatingEdge: tempFloatingEdge,
setTempFloatingEdge: setTempFloatingEdge,
startFloatingEdgeCreation: startFloatingEdgeCreation,
endFloatingEdgeCreation: endFloatingEdgeCreation,
hoveringTableId,
setHoveringTableId,
showCreateRelationshipNode,
hideCreateRelationshipNode,
}}
>
{children}

View File

@@ -13,6 +13,55 @@ import { exportSQLite } from './export-per-type/sqlite';
import { exportMySQL } from './export-per-type/mysql';
import { escapeSQLComment } from './export-per-type/common';
// Function to format default values with proper quoting
const formatDefaultValue = (value: string): string => {
const trimmed = value.trim();
// SQL keywords and function-like keywords that don't need quotes
const keywords = [
'TRUE',
'FALSE',
'NULL',
'CURRENT_TIMESTAMP',
'CURRENT_DATE',
'CURRENT_TIME',
'NOW',
'GETDATE',
'NEWID',
'UUID',
];
if (keywords.includes(trimmed.toUpperCase())) {
return trimmed;
}
// Function calls (contain parentheses) don't need quotes
if (trimmed.includes('(') && trimmed.includes(')')) {
return trimmed;
}
// Numbers don't need quotes
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return trimmed;
}
// Already quoted strings - keep as is
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed;
}
// Check if it's a simple identifier (alphanumeric, no spaces) that might be a currency or enum
// These typically don't have spaces and are short (< 10 chars)
if (/^[A-Z][A-Z0-9_]*$/i.test(trimmed) && trimmed.length <= 10) {
return trimmed; // Treat as unquoted identifier (e.g., EUR, USD)
}
// Everything else needs to be quoted and escaped
return `'${trimmed.replace(/'/g, "''")}'`;
};
// Function to simplify verbose data type names
const simplifyDataType = (typeName: string): string => {
const typeMap: Record<string, string> = {};
@@ -391,7 +440,9 @@ export const exportBaseSQL = ({
}
}
sqlScript += ` DEFAULT ${fieldDefault}`;
// Format default value with proper quoting
const formattedDefault = formatDefaultValue(fieldDefault);
sqlScript += ` DEFAULT ${formattedDefault}`;
}
}

View File

@@ -295,4 +295,51 @@ describe('DBML Import cases', () => {
it('should handle case 2 - tables with relationships', async () => {
await testDBMLImportCase('2');
});
it('should handle table with default values', async () => {
const dbmlContent = `Table "public"."products" {
"id" bigint [pk, not null]
"name" varchar(255) [not null]
"price" decimal(10,2) [not null, default: 0]
"is_active" boolean [not null, default: true]
"status" varchar(50) [not null, default: "deprecated"]
"description" varchar(100) [default: \`complex "value" with quotes\`]
"created_at" timestamp [not null, default: "now()"]
Indexes {
(name) [name: "idx_products_name"]
}
}`;
const result = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(result.tables).toHaveLength(1);
const table = result.tables![0];
expect(table.name).toBe('products');
expect(table.fields).toHaveLength(7);
// Check numeric default (0)
const priceField = table.fields.find((f) => f.name === 'price');
expect(priceField?.default).toBe('0');
// Check boolean default (true)
const isActiveField = table.fields.find((f) => f.name === 'is_active');
expect(isActiveField?.default).toBe('true');
// Check string default with all quotes removed
const statusField = table.fields.find((f) => f.name === 'status');
expect(statusField?.default).toBe('deprecated');
// Check backtick string - all quotes removed
const descField = table.fields.find((f) => f.name === 'description');
expect(descField?.default).toBe('complex value with quotes');
// Check function default with all quotes removed
const createdAtField = table.fields.find(
(f) => f.name === 'created_at'
);
expect(createdAtField?.default).toBe('now()');
});
});

View File

@@ -89,6 +89,7 @@ interface DBMLField {
precision?: number | null;
scale?: number | null;
note?: string | { value: string } | null;
default?: string | null;
}
interface DBMLIndexColumn {
@@ -334,6 +335,20 @@ export const importDBMLToDiagram = async (
schema: schemaName,
note: table.note,
fields: table.fields.map((field): DBMLField => {
// Extract default value and remove all quotes
let defaultValue: string | undefined;
if (
field.dbdefault !== undefined &&
field.dbdefault !== null
) {
const rawDefault = String(
field.dbdefault.value
);
// Remove ALL quotes (single, double, backticks) to clean the value
// The SQL export layer will handle adding proper quotes when needed
defaultValue = rawDefault.replace(/['"`]/g, '');
}
return {
name: field.name,
type: field.type,
@@ -342,6 +357,7 @@ export const importDBMLToDiagram = async (
not_null: field.not_null,
increment: field.increment,
note: field.note,
default: defaultValue,
...getFieldExtraAttributes(field, allEnums),
} satisfies DBMLField;
}),
@@ -488,6 +504,7 @@ export const importDBMLToDiagram = async (
precision: field.precision,
scale: field.scale,
...(fieldComment ? { comments: fieldComment } : {}),
...(field.default ? { default: field.default } : {}),
};
});

View File

@@ -14,14 +14,14 @@ import { Table, Workflow, Group, View } from 'lucide-react';
import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter';
import { useLocalConfig } from '@/hooks/use-local-config';
import { useCanvas } from '@/hooks/use-canvas';
import type { DBTable } from '@/lib/domain';
import { defaultSchemas } from '@/lib/data/default-schemas';
export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const { createTable, readonly, createArea } = useChartDB();
const { createTable, readonly, createArea, databaseType } = useChartDB();
const { schemasDisplayed } = useDiagramFilter();
const { openCreateRelationshipDialog, openTableSchemaDialog } = useDialog();
const { openCreateRelationshipDialog } = useDialog();
const { screenToFlowPosition } = useReactFlow();
const { t } = useTranslation();
const { showDBViews } = useLocalConfig();
@@ -36,31 +36,24 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
y: event.clientY,
});
let newTable: DBTable | null = null;
if (schemasDisplayed.length > 1) {
openTableSchemaDialog({
onConfirm: async ({ schema }) => {
newTable = await createTable({
x: position.x,
y: position.y,
schema: schema.name,
});
},
schemas: schemasDisplayed,
});
} else {
const schema =
schemasDisplayed?.length === 1
? schemasDisplayed[0]?.name
: undefined;
newTable = await createTable({
x: position.x,
y: position.y,
schema,
});
// Auto-select schema with priority: default schema > first displayed schema > undefined
let schema: string | undefined = undefined;
if (schemasDisplayed.length > 0) {
const defaultSchemaName = defaultSchemas[databaseType];
const defaultSchemaInList = schemasDisplayed.find(
(s) => s.name === defaultSchemaName
);
schema = defaultSchemaInList
? defaultSchemaInList.name
: schemasDisplayed[0]?.name;
}
const newTable = await createTable({
x: position.x,
y: position.y,
schema,
});
if (newTable) {
setEditTableModeTable({ tableId: newTable.id });
}
@@ -68,9 +61,9 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
[
createTable,
screenToFlowPosition,
openTableSchemaDialog,
schemasDisplayed,
setEditTableModeTable,
databaseType,
]
);
@@ -81,33 +74,25 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
y: event.clientY,
});
let newView: DBTable | null = null;
if (schemasDisplayed.length > 1) {
openTableSchemaDialog({
onConfirm: async ({ schema }) => {
newView = await createTable({
x: position.x,
y: position.y,
schema: schema.name,
isView: true,
});
},
schemas: schemasDisplayed,
});
} else {
const schema =
schemasDisplayed?.length === 1
? schemasDisplayed[0]?.name
: undefined;
newView = await createTable({
x: position.x,
y: position.y,
schema,
isView: true,
});
// Auto-select schema with priority: default schema > first displayed schema > undefined
let schema: string | undefined = undefined;
if (schemasDisplayed.length > 0) {
const defaultSchemaName = defaultSchemas[databaseType];
const defaultSchemaInList = schemasDisplayed.find(
(s) => s.name === defaultSchemaName
);
schema = defaultSchemaInList
? defaultSchemaInList.name
: schemasDisplayed[0]?.name;
}
const newView = await createTable({
x: position.x,
y: position.y,
schema,
isView: true,
});
if (newView) {
setEditTableModeTable({ tableId: newView.id });
}
@@ -115,9 +100,9 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
[
createTable,
screenToFlowPosition,
openTableSchemaDialog,
schemasDisplayed,
setEditTableModeTable,
databaseType,
]
);

View File

@@ -30,7 +30,11 @@ import {
import '@xyflow/react/dist/style.css';
import equal from 'fast-deep-equal';
import type { TableNodeType } from './table-node/table-node';
import { TableNode } from './table-node/table-node';
import {
TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX,
TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX,
TableNode,
} from './table-node/table-node';
import type { RelationshipEdgeType } from './relationship-edge/relationship-edge';
import { RelationshipEdge } from './relationship-edge/relationship-edge';
import { useChartDB } from '@/hooks/use-chartdb';
@@ -79,6 +83,20 @@ import { useCanvas } from '@/hooks/use-canvas';
import type { AreaNodeType } from './area-node/area-node';
import { AreaNode } from './area-node/area-node';
import type { Area } from '@/lib/domain/area';
import type { TempCursorNodeType } from './temp-cursor-node/temp-cursor-node';
import {
TEMP_CURSOR_HANDLE_ID,
TEMP_CURSOR_NODE_ID,
TempCursorNode,
} from './temp-cursor-node/temp-cursor-node';
import type { TempFloatingEdgeType } from './temp-floating-edge/temp-floating-edge';
import {
TEMP_FLOATING_EDGE_ID,
TempFloatingEdge,
} from './temp-floating-edge/temp-floating-edge';
import type { CreateRelationshipNodeType } from './create-relationship-node/create-relationship-node';
import { CreateRelationshipNode } from './create-relationship-node/create-relationship-node';
import { ConnectionLine } from './connection-line/connection-line';
import {
updateTablesParentAreas,
getTablesInArea,
@@ -93,26 +111,34 @@ 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;
export type EdgeType = RelationshipEdgeType | DependencyEdgeType;
export type EdgeType =
| RelationshipEdgeType
| DependencyEdgeType
| TempFloatingEdgeType;
export type NodeType = TableNodeType | AreaNodeType;
export type NodeType =
| TableNodeType
| AreaNodeType
| TempCursorNodeType
| CreateRelationshipNodeType;
type AddEdgeParams = Parameters<typeof addEdge<EdgeType>>[0];
const edgeTypes: EdgeTypes = {
'relationship-edge': RelationshipEdge,
'dependency-edge': DependencyEdge,
'temp-floating-edge': TempFloatingEdge,
};
const nodeTypes: NodeTypes = {
table: TableNode,
area: AreaNode,
'temp-cursor': TempCursorNode,
'create-relationship': CreateRelationshipNode,
};
const initialEdges: EdgeType[] = [];
@@ -125,21 +151,14 @@ const tableToTableNode = (
filterLoading,
showDBViews,
forceShow,
onStartRelationship,
pendingRelationshipSource,
fieldSelectionDialog,
isRelationshipCreatingTarget = false,
}: {
filter?: DiagramFilter;
databaseType: DatabaseType;
filterLoading: boolean;
showDBViews?: boolean;
forceShow?: boolean;
onStartRelationship?: (sourceTableId: string) => void;
pendingRelationshipSource?: string | null;
fieldSelectionDialog?: {
sourceTableId: string;
targetTableId: string;
} | null;
isRelationshipCreatingTarget?: boolean;
}
): TableNodeType => {
// Always use absolute position for now
@@ -160,13 +179,6 @@ 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',
@@ -174,13 +186,7 @@ const tableToTableNode = (
data: {
table,
isOverlapping: false,
onStartRelationship,
isPendingRelationshipTarget:
pendingRelationshipSource !== null &&
pendingRelationshipSource !== table.id,
isDialogSource,
isDialogTarget,
isPendingRelationshipSource,
isRelationshipCreatingTarget,
},
width: table.width ?? MIN_TABLE_SIZE,
hidden,
@@ -277,24 +283,14 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
showFilter,
setShowFilter,
setEditTableModeTable,
tempFloatingEdge,
endFloatingEdgeCreation,
hoveringTableId,
hideCreateRelationshipNode,
} = useCanvas();
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 });
@@ -304,7 +300,6 @@ 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, {
@@ -313,55 +308,20 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
onStartRelationship: undefined, // Will be set later
isRelationshipCreatingTarget: false,
})
)
);
const [edges, setEdges, onEdgesChange] =
useEdgesState<EdgeType>(initialEdges);
// Callback to handle "Add Relationship" from context menu
const handleStartRelationship = useCallback(
(sourceTableId: string) => {
// 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 [cursorPosition, setCursorPosition] = useState<{
x: number;
y: number;
} | null>(null);
useEffect(() => {
setIsInitialLoadingNodes(true);
}, [initialTables]);
@@ -374,7 +334,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
onStartRelationship: handleStartRelationship,
isRelationshipCreatingTarget: false,
})
);
if (equal(initialNodes, nodes)) {
@@ -388,7 +348,6 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
shouldForceShowTable,
handleStartRelationship,
]);
useEffect(() => {
@@ -422,39 +381,31 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
{} as Record<string, number>
);
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] : []),
];
});
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,
})
),
]);
}, [relationships, dependencies, setEdges, showDBViews]);
useEffect(() => {
@@ -489,58 +440,62 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
// Check if any edge needs updating
let hasChanges = false;
const newEdges = prevEdges.map((edge): EdgeType => {
const shouldBeHighlighted =
selectedRelationshipIdsSet.has(edge.id) ||
selectedTableIdsSet.has(edge.source) ||
selectedTableIdsSet.has(edge.target);
const newEdges = prevEdges
.filter((e) => e.type !== 'temp-floating-edge')
.map((edge): EdgeType => {
const shouldBeHighlighted =
selectedRelationshipIdsSet.has(edge.id) ||
selectedTableIdsSet.has(edge.source) ||
selectedTableIdsSet.has(edge.target);
const currentHighlighted = edge.data?.highlighted ?? false;
const currentAnimated = edge.animated ?? false;
const currentZIndex = edge.zIndex ?? 0;
const currentHighlighted =
(edge as Exclude<EdgeType, TempFloatingEdgeType>).data
?.highlighted ?? false;
const currentAnimated = edge.animated ?? false;
const currentZIndex = edge.zIndex ?? 0;
// Skip if no changes needed
if (
currentHighlighted === shouldBeHighlighted &&
currentAnimated === shouldBeHighlighted &&
currentZIndex ===
(shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX)
) {
return edge;
}
// Skip if no changes needed
if (
currentHighlighted === shouldBeHighlighted &&
currentAnimated === shouldBeHighlighted &&
currentZIndex ===
(shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX)
) {
return edge;
}
hasChanges = true;
hasChanges = true;
if (edge.type === 'dependency-edge') {
const dependencyEdge = edge as DependencyEdgeType;
return {
...dependencyEdge,
data: {
...dependencyEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
} else {
const relationshipEdge = edge as RelationshipEdgeType;
return {
...relationshipEdge,
data: {
...relationshipEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
}
});
if (edge.type === 'dependency-edge') {
const dependencyEdge = edge as DependencyEdgeType;
return {
...dependencyEdge,
data: {
...dependencyEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
} else {
const relationshipEdge = edge as RelationshipEdgeType;
return {
...relationshipEdge,
data: {
...relationshipEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
}
});
return hasChanges ? newEdges : prevEdges;
});
@@ -558,9 +513,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
onStartRelationship: handleStartRelationship,
pendingRelationshipSource,
fieldSelectionDialog,
isRelationshipCreatingTarget: false,
});
// Check if table uses the highlighted custom type
@@ -590,6 +543,11 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
})
),
...prevNodes.filter(
(n) =>
n.type === 'temp-cursor' ||
n.type === 'create-relationship'
),
];
// Check if nodes actually changed
@@ -612,11 +570,39 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
shouldForceShowTable,
pendingRelationshipSource,
fieldSelectionDialog,
handleStartRelationship,
]);
// Surgical update for relationship creation target highlighting
// This avoids expensive full node recalculation when only the visual state changes
useEffect(() => {
setNodes((nds) => {
let hasChanges = false;
const updatedNodes = nds.map((node) => {
if (node.type !== 'table') return node;
const shouldBeTarget =
!!tempFloatingEdge?.sourceNodeId &&
node.id !== tempFloatingEdge.sourceNodeId;
const isCurrentlyTarget =
node.data.isRelationshipCreatingTarget ?? false;
if (shouldBeTarget !== isCurrentlyTarget) {
hasChanges = true;
return {
...node,
data: {
...node.data,
isRelationshipCreatingTarget: shouldBeTarget,
},
};
}
return node;
});
return hasChanges ? updatedNodes : nds;
});
}, [tempFloatingEdge?.sourceNodeId, setNodes]);
const prevFilter = useRef<DiagramFilter | undefined>(undefined);
useEffect(() => {
if (!equal(filter, prevFilter.current)) {
@@ -722,32 +708,6 @@ 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;
// 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(() => {
return {
sourceTableId,
targetTableId,
};
});
return;
}
const sourceTableId = params.source;
const targetTableId = params.target;
const sourceFieldId = params.sourceHandle?.split('_')?.pop() ?? '';
@@ -782,67 +742,9 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
targetFieldId,
});
},
[
createRelationship,
createDependency,
getField,
toast,
databaseType,
showFilter,
setShowFilter,
]
[createRelationship, createDependency, getField, toast, databaseType]
);
const onConnectStart = useCallback(() => {
setIsConnecting(true);
}, []);
const onConnectEnd = useCallback(() => {
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;
@@ -1408,6 +1310,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
[setEditTableModeTable]
);
useClickAway(containerRef, exitEditTableMode);
useClickAway(containerRef, hideCreateRelationshipNode);
const shiftPressed = useKeyPress('Shift');
const operatingSystem = getOperatingSystem();
@@ -1424,83 +1327,139 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
[]
);
// Handle mouse move to update cursor position for floating edge
const { screenToFlowPosition } = useReactFlow();
const rafIdRef = useRef<number>();
const handleMouseMove = useCallback(
(event: React.MouseEvent) => {
if (tempFloatingEdge) {
// Throttle using requestAnimationFrame
if (rafIdRef.current) {
return;
}
rafIdRef.current = requestAnimationFrame(() => {
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
setCursorPosition(position);
rafIdRef.current = undefined;
});
}
},
[tempFloatingEdge, screenToFlowPosition]
);
// Cleanup RAF on unmount
useEffect(() => {
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, []);
// Handle escape key to cancel floating edge creation and close relationship node
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (tempFloatingEdge) {
endFloatingEdgeCreation();
setCursorPosition(null);
}
// Also close CreateRelationshipNode if present
hideCreateRelationshipNode();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [tempFloatingEdge, endFloatingEdgeCreation, hideCreateRelationshipNode]);
// Add temporary invisible node at cursor position and edge
const nodesWithCursor = useMemo(() => {
if (!tempFloatingEdge || !cursorPosition) {
return nodes;
}
const tempNode: TempCursorNodeType = {
id: TEMP_CURSOR_NODE_ID,
type: 'temp-cursor',
position: cursorPosition,
data: {},
draggable: false,
selectable: false,
};
return [...nodes, tempNode];
}, [nodes, tempFloatingEdge, cursorPosition]);
const edgesWithFloating = useMemo(() => {
if (!tempFloatingEdge || !cursorPosition) return edges;
let target = TEMP_CURSOR_NODE_ID;
let targetHandle: string | undefined = TEMP_CURSOR_HANDLE_ID;
if (tempFloatingEdge.targetNodeId) {
target = tempFloatingEdge.targetNodeId;
targetHandle = `${TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX}${tempFloatingEdge.targetNodeId}`;
} else if (
hoveringTableId &&
hoveringTableId !== tempFloatingEdge.sourceNodeId
) {
target = hoveringTableId;
targetHandle = `${TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX}${hoveringTableId}`;
}
const tempEdge: TempFloatingEdgeType = {
id: TEMP_FLOATING_EDGE_ID,
source: tempFloatingEdge.sourceNodeId,
sourceHandle: `${TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX}${tempFloatingEdge.sourceNodeId}`,
target,
targetHandle,
type: 'temp-floating-edge',
};
return [...edges, tempEdge];
}, [edges, tempFloatingEdge, cursorPosition, hoveringTableId]);
const onPaneClickHandler = useCallback(() => {
if (tempFloatingEdge) {
endFloatingEdgeCreation();
setCursorPosition(null);
}
// Close CreateRelationshipNode if it exists
hideCreateRelationshipNode();
// Exit edit table mode
exitEditTableMode();
}, [
tempFloatingEdge,
exitEditTableMode,
endFloatingEdgeCreation,
hideCreateRelationshipNode,
]);
return (
<CanvasContextMenu>
<div
className="relative flex h-full"
id="canvas"
ref={containerRef}
onMouseMove={handleMouseMove}
>
<ReactFlow
onlyRenderVisibleElements
colorMode={effectiveTheme}
className={`${pendingRelationshipSource ? 'cursor-crosshair' : ''} nodes-animated`}
nodes={nodes}
edges={edges}
className="canvas-cursor-default nodes-animated"
nodes={nodesWithCursor}
edges={edgesWithFloating}
onNodesChange={onNodesChangeHandler}
onEdgesChange={onEdgesChangeHandler}
maxZoom={5}
minZoom={0.1}
onConnect={onConnectHandler}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onNodeClick={(_event, node) => {
// Handle pending relationship creation
if (
pendingRelationshipSource &&
node.type === 'table'
) {
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,
}}
@@ -1511,14 +1470,11 @@ 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]}
onPaneClick={exitEditTableMode}
onPaneClick={onPaneClickHandler}
connectionLineComponent={ConnectionLine}
>
<Controls
position="top-left"
@@ -1701,20 +1657,6 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
<CanvasFilter onClose={() => setShowFilter(false)} />
) : null}
</ReactFlow>
{/* Render overlay outside ReactFlow but inside canvas container to ensure proper z-index */}
{fieldSelectionDialog && (
<div
className="pointer-events-none absolute inset-0"
style={{ zIndex: 100 }}
>
<SelectRelationshipFieldsOverlay
key={`${fieldSelectionDialog.sourceTableId}-${fieldSelectionDialog.targetTableId}`}
sourceTableId={fieldSelectionDialog.sourceTableId}
targetTableId={fieldSelectionDialog.targetTableId}
onClose={() => setFieldSelectionDialog(null)}
/>
</div>
)}
<MarkerDefinitions />
</div>
</CanvasContextMenu>

View File

@@ -0,0 +1,38 @@
import React from 'react';
import type { ConnectionLineComponentProps } from '@xyflow/react';
import { getSmoothStepPath, Position } from '@xyflow/react';
import type { NodeType } from '../canvas';
export const ConnectionLine: React.FC<
ConnectionLineComponentProps<NodeType>
> = ({ fromX, fromY, toX, toY, fromPosition, toPosition }) => {
const [edgePath] = getSmoothStepPath({
sourceX: fromX,
sourceY: fromY,
sourcePosition: fromPosition ?? Position.Right,
targetX: toX,
targetY: toY,
targetPosition: toPosition ?? Position.Left,
borderRadius: 14,
});
return (
<g>
<path
fill="none"
stroke="#ec4899"
strokeWidth={2}
strokeDasharray="5,5"
d={edgePath}
/>
<circle
cx={toX}
cy={toY}
fill="#fff"
r={3}
stroke="#ec4899"
strokeWidth={1.5}
/>
</g>
);
};

View File

@@ -1,75 +1,45 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { NodeProps, Node } from '@xyflow/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 { ArrowRight, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { generateId } from '@/lib/utils';
import type { DBField } from '@/lib/domain/db-field';
import { useReactFlow } from '@xyflow/react';
import { useCanvas } from '@/hooks/use-canvas';
export interface SelectRelationshipFieldsOverlayProps {
sourceTableId: string;
targetTableId: string;
onClose: () => void;
}
export const CREATE_RELATIONSHIP_NODE_ID = '__create-relationship-node__';
export const SelectRelationshipFieldsOverlay: React.FC<
SelectRelationshipFieldsOverlayProps
> = ({ sourceTableId, targetTableId, onClose }) => {
const CREATE_NEW_FIELD_VALUE = 'CREATE_NEW';
export type CreateRelationshipNodeType = Node<
{
sourceTableId: string;
targetTableId: string;
},
'create-relationship'
>;
export const CreateRelationshipNode: React.FC<
NodeProps<CreateRelationshipNodeType>
> = React.memo(({ data }) => {
const { sourceTableId, targetTableId } = data;
const { getTable, createRelationship, databaseType, addField } =
useChartDB();
const { hideCreateRelationshipNode } = useCanvas();
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]
@@ -89,6 +59,14 @@ export const SelectRelationshipFieldsOverlay: React.FC<
}, [sourceTable]);
// Get compatible target fields (FK columns)
// Reset state when source or target table changes
useEffect(() => {
setTargetFieldId(undefined);
setSearchTerm('');
setErrorMessage('');
setSelectOpen(true);
}, [sourceTableId, targetTableId]);
const targetFieldOptions = useMemo(() => {
if (!targetTable || !sourcePKField) return [];
@@ -118,7 +96,7 @@ export const SelectRelationshipFieldsOverlay: React.FC<
) {
compatibleFields.push({
label: `Create "${searchTerm}"`,
value: 'CREATE_NEW',
value: CREATE_NEW_FIELD_VALUE,
description: `(${sourcePKField.type.name})`,
});
}
@@ -126,7 +104,7 @@ export const SelectRelationshipFieldsOverlay: React.FC<
return compatibleFields;
}, [targetTable, sourcePKField, databaseType, searchTerm]);
// Auto-select first compatible field OR pre-populate suggested name (only once on mount)
// Auto-select first compatible field OR pre-populate suggested name
useEffect(() => {
if (targetFieldOptions.length > 0 && !targetFieldId) {
setTargetFieldId(targetFieldOptions[0].value as string);
@@ -143,85 +121,24 @@ export const SelectRelationshipFieldsOverlay: React.FC<
: sourcePKField.name;
setSearchTerm(suggestedName);
}
}, [targetFieldOptions.length, sourceTable, sourcePKField]); // eslint-disable-line react-hooks/exhaustive-deps
}, [
targetFieldOptions.length,
sourceTable,
sourcePKField,
searchTerm,
targetFieldId,
targetFieldOptions,
]);
// 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;
@@ -229,7 +146,7 @@ export const SelectRelationshipFieldsOverlay: React.FC<
let finalTargetFieldId = targetFieldId;
// If user selected "CREATE_NEW", create the field first
if (targetFieldId === 'CREATE_NEW' && searchTerm) {
if (targetFieldId === CREATE_NEW_FIELD_VALUE && searchTerm) {
const newField: DBField = {
id: generateId(),
name: searchTerm,
@@ -240,11 +157,20 @@ export const SelectRelationshipFieldsOverlay: React.FC<
createdAt: Date.now(),
};
await addField(targetTableId, newField);
finalTargetFieldId = newField.id;
try {
await addField(targetTableId, newField);
finalTargetFieldId = newField.id;
} catch (fieldError) {
console.error('Failed to create field:', fieldError);
setErrorMessage('Failed to create new field');
return;
}
}
if (!finalTargetFieldId) return;
if (!finalTargetFieldId) {
setErrorMessage('Please select a target field');
return;
}
const relationship = await createRelationship({
sourceTableId,
@@ -262,7 +188,7 @@ export const SelectRelationshipFieldsOverlay: React.FC<
);
openRelationshipFromSidebar(relationship.id);
onClose();
hideCreateRelationshipNode();
} catch (error) {
console.error(error);
setErrorMessage('Failed to create relationship');
@@ -277,20 +203,10 @@ export const SelectRelationshipFieldsOverlay: React.FC<
addField,
setEdges,
openRelationshipFromSidebar,
onClose,
hideCreateRelationshipNode,
]);
// 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]);
// Note: Escape key handling is done in canvas.tsx to avoid duplicate listeners
if (!sourceTable || !targetTable || !sourcePKField) {
return null;
@@ -299,40 +215,28 @@ export const SelectRelationshipFieldsOverlay: React.FC<
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',
'pointer-events-auto flex cursor-auto flex-col rounded-lg border border-slate-300 bg-white shadow-xl transition-all duration-150 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">
<div className="flex cursor-move items-center justify-between gap-2 rounded-t-[7px] border-b bg-sky-600 px-3 py-1 dark:border-slate-600 dark:bg-sky-800">
<div className="text-xs 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}
className="size-6 p-0 text-white hover:bg-white/20 hover:text-white dark:hover:bg-white/10"
onClick={hideCreateRelationshipNode}
>
<X className="size-4" />
</Button>
@@ -346,7 +250,7 @@ export const SelectRelationshipFieldsOverlay: React.FC<
<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">
<div className="flex h-7 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">
@@ -355,19 +259,8 @@ export const SelectRelationshipFieldsOverlay: React.FC<
</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 className="flex items-center">
<ArrowRight className="size-3.5 text-slate-400 dark:text-slate-500" />
</div>
{/* FK Column (Target) */}
@@ -376,8 +269,8 @@ export const SelectRelationshipFieldsOverlay: React.FC<
To (FK)
</label>
<SelectBox
className="flex h-9 w-full dark:border-slate-200"
popoverClassName="!z-[1001]" // Higher than the dialog's z-index
className="flex h-7 min-h-0 w-full dark:border-slate-200"
popoverClassName="!z-[1001]"
options={targetFieldOptions}
placeholder="Select field..."
inputPlaceholder="Search or Create..."
@@ -410,16 +303,19 @@ export const SelectRelationshipFieldsOverlay: React.FC<
</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">
<div className="flex cursor-move 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"
variant="default"
className="h-7 bg-sky-600 px-3 text-xs text-white hover:bg-sky-700 dark:bg-sky-800 dark:text-white dark:hover:bg-sky-900"
>
Create
</Button>
</div>
</div>
);
};
});
CreateRelationshipNode.displayName = 'CreateRelationshipNode';

View File

@@ -1,7 +1,13 @@
import { Input } from '@/components/input/input';
import type { DBTable } from '@/lib/domain';
import { FileType2, X } from 'lucide-react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { FileType2, X, SquarePlus } from 'lucide-react';
import React, {
useEffect,
useState,
useRef,
useCallback,
useMemo,
} from 'react';
import { TableEditModeField } from './table-edit-mode-field';
import { cn } from '@/lib/utils';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
@@ -11,6 +17,14 @@ import { Separator } from '@/components/separator/separator';
import { useChartDB } from '@/hooks/use-chartdb';
import { useUpdateTable } from '@/hooks/use-update-table';
import { useTranslation } from 'react-i18next';
import { SelectBox } from '@/components/select-box/select-box';
import type { SelectBoxOption } from '@/components/select-box/select-box';
import {
databasesWithSchemas,
schemaNameToSchemaId,
} from '@/lib/domain/db-schema';
import type { DBSchema } from '@/lib/domain/db-schema';
import { defaultSchemas } from '@/lib/data/default-schemas';
export interface TableEditModeProps {
table: DBTable;
@@ -25,7 +39,8 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
const scrollAreaRef = useRef<HTMLDivElement>(null);
const fieldRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [isVisible, setIsVisible] = useState(false);
const { createField, updateTable } = useChartDB();
const { createField, updateTable, schemas, databaseType } =
useChartDB();
const { t } = useTranslation();
const { tableName, handleTableNameChange } = useUpdateTable(table);
const [focusFieldId, setFocusFieldId] = useState<string | undefined>(
@@ -33,6 +48,39 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
);
const inputRef = useRef<HTMLInputElement>(null);
// Schema-related state
const [isCreatingNewSchema, setIsCreatingNewSchema] = useState(false);
const [newSchemaName, setNewSchemaName] = useState('');
const [selectedSchemaId, setSelectedSchemaId] = useState<string>(() =>
table.schema ? schemaNameToSchemaId(table.schema) : ''
);
// Sync selectedSchemaId when table.schema changes
useEffect(() => {
setSelectedSchemaId(
table.schema ? schemaNameToSchemaId(table.schema) : ''
);
}, [table.schema]);
const supportsSchemas = useMemo(
() => databasesWithSchemas.includes(databaseType),
[databaseType]
);
const defaultSchemaName = useMemo(
() => defaultSchemas?.[databaseType],
[databaseType]
);
const schemaOptions: SelectBoxOption[] = useMemo(
() =>
schemas.map((schema) => ({
value: schema.id,
label: schema.name,
})),
[schemas]
);
useEffect(() => {
setFocusFieldId(focusFieldIdProp);
if (!focusFieldIdProp) {
@@ -115,6 +163,43 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
[updateTable, table.id]
);
const handleSchemaChange = useCallback(
(schemaId: string) => {
const schema = schemas.find((s) => s.id === schemaId);
if (schema) {
updateTable(table.id, { schema: schema.name });
setSelectedSchemaId(schemaId);
}
},
[schemas, updateTable, table.id]
);
const handleCreateNewSchema = useCallback(() => {
if (newSchemaName.trim()) {
const trimmedName = newSchemaName.trim();
const newSchema: DBSchema = {
id: schemaNameToSchemaId(trimmedName),
name: trimmedName,
tableCount: 0,
};
updateTable(table.id, { schema: newSchema.name });
setSelectedSchemaId(newSchema.id);
setIsCreatingNewSchema(false);
setNewSchemaName('');
}
}, [newSchemaName, updateTable, table.id]);
const handleToggleSchemaMode = useCallback(() => {
if (isCreatingNewSchema && newSchemaName.trim()) {
// If we're leaving create mode with a value, create the schema
handleCreateNewSchema();
} else {
// Otherwise just toggle modes
setIsCreatingNewSchema(!isCreatingNewSchema);
setNewSchemaName('');
}
}, [isCreatingNewSchema, newSchemaName, handleCreateNewSchema]);
return (
<div
ref={containerRef}
@@ -134,18 +219,60 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
onClick={(e) => e.stopPropagation()}
>
<div
className="h-2 rounded-t-[6px]"
className="h-2 cursor-move rounded-t-[6px]"
style={{ backgroundColor: color }}
></div>
<div className="group flex h-9 items-center justify-between gap-2 bg-slate-200 px-2 dark:bg-slate-900">
<div className="group flex h-9 cursor-move items-center justify-between gap-2 bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ColorPicker
color={color}
onChange={handleColorChange}
disabled={table.isView}
popoverOnMouseDown={(e) => e.stopPropagation()}
popoverOnClick={(e) => e.stopPropagation()}
/>
{supportsSchemas && !isCreatingNewSchema && (
<SelectBox
options={schemaOptions}
value={selectedSchemaId}
onChange={(value) =>
handleSchemaChange(value as string)
}
placeholder={
defaultSchemaName || 'Select schema'
}
className="h-6 min-h-6 w-20 shrink-0 rounded-sm border-slate-600 bg-background py-0 pl-2 pr-0.5 text-sm"
popoverClassName="w-[200px]"
commandOnMouseDown={(e) => e.stopPropagation()}
commandOnClick={(e) => e.stopPropagation()}
footerButtons={
<Button
variant="ghost"
size="sm"
className="w-full justify-center rounded-none text-xs"
onClick={(e) => {
e.stopPropagation();
handleToggleSchemaMode();
}}
>
<SquarePlus className="!size-3.5" />
Create new schema
</Button>
}
/>
)}
{supportsSchemas && isCreatingNewSchema && (
<Input
value={newSchemaName}
onChange={(e) =>
setNewSchemaName(e.target.value)
}
placeholder={`Enter schema name${defaultSchemaName ? ` (e.g. ${defaultSchemaName})` : ''}`}
className="h-6 w-28 shrink-0 rounded-sm border-slate-600 bg-background text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateNewSchema();
} else if (e.key === 'Escape') {
handleToggleSchemaMode();
}
}}
onBlur={handleToggleSchemaMode}
autoFocus
/>
)}
<Input
ref={inputRef}
className="h-6 flex-1 rounded-sm border-slate-600 bg-background text-sm"
@@ -179,15 +306,31 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
</ScrollArea>
<Separator />
<div className="flex items-center justify-between p-2">
<Button
variant="outline"
className="h-8 p-2 text-xs"
onClick={handleAddField}
>
<FileType2 className="mr-1 h-4" />
{t('side_panel.tables_section.table.add_field')}
</Button>
<div className="flex cursor-move items-center justify-between p-2">
<div className="flex items-center gap-2">
{!table.isView ? (
<>
<ColorPicker
color={color}
onChange={handleColorChange}
popoverOnMouseDown={(e) =>
e.stopPropagation()
}
popoverOnClick={(e) => e.stopPropagation()}
/>
</>
) : (
<div />
)}
<Button
variant="outline"
className="h-8 p-2 text-xs"
onClick={handleAddField}
>
<FileType2 className="mr-1 h-4" />
{t('side_panel.tables_section.table.add_field')}
</Button>
</div>
<span className="text-xs font-medium text-muted-foreground">
{table.fields.length}{' '}
{t('side_panel.tables_section.table.fields')}

View File

@@ -12,57 +12,69 @@ import type { DBTable } from '@/lib/domain/db-table';
import { Copy, Pencil, Trash2, Workflow } from 'lucide-react';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDialog } from '@/hooks/use-dialog';
import { useCanvas } from '@/hooks/use-canvas';
export interface TableNodeContextMenuProps {
table: DBTable;
onStartRelationshipCreation?: () => void;
}
export const TableNodeContextMenu: React.FC<
React.PropsWithChildren<TableNodeContextMenuProps>
> = ({ children, table, onStartRelationshipCreation }) => {
> = ({ children, table }) => {
const { removeTable, readonly, createTable } = useChartDB();
const { closeAllTablesInSidebar } = useLayout();
const { t } = useTranslation();
const { isMd: isDesktop } = useBreakpoint('md');
const { openCreateRelationshipDialog } = useDialog();
const { setEditTableModeTable } = useCanvas();
const { setEditTableModeTable, startFloatingEdgeCreation } = useCanvas();
const duplicateTableHandler = useCallback(() => {
const clonedTable = cloneTable(table);
const duplicateTableHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
const clonedTable = cloneTable(table);
clonedTable.name = `${clonedTable.name}_copy`;
clonedTable.x += 30;
clonedTable.y += 50;
clonedTable.name = `${clonedTable.name}_copy`;
clonedTable.x += 30;
clonedTable.y += 50;
createTable(clonedTable);
}, [createTable, table]);
createTable(clonedTable);
},
[createTable, table]
);
const editTableHandler = useCallback(() => {
if (readonly) {
return;
}
const editTableHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
if (readonly) {
return;
}
closeAllTablesInSidebar();
setEditTableModeTable({ tableId: table.id });
}, [table.id, setEditTableModeTable, closeAllTablesInSidebar, readonly]);
closeAllTablesInSidebar();
setEditTableModeTable({ tableId: table.id });
},
[table.id, setEditTableModeTable, closeAllTablesInSidebar, readonly]
);
const removeTableHandler = useCallback(() => {
removeTable(table.id);
}, [removeTable, table.id]);
const removeTableHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
removeTable(table.id);
},
[removeTable, table.id]
);
const addRelationshipHandler = useCallback(() => {
// Use the programmatic handle drag if available, otherwise fall back to dialog
if (onStartRelationshipCreation) {
onStartRelationshipCreation();
} else {
openCreateRelationshipDialog({
sourceTableId: table.id,
});
}
}, [onStartRelationshipCreation, openCreateRelationshipDialog, table.id]);
const addRelationshipHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
startFloatingEdgeCreation({
sourceNodeId: table.id,
});
},
[startFloatingEdgeCreation, table.id]
);
if (!isDesktop || readonly) {
return <>{children}</>;

View File

@@ -53,7 +53,7 @@ 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_SOURCE_HANDLE_ID_PREFIX = 'table_rel_source_';
export const TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX = 'table_rel_target_';
export type TableNodeType = Node<
@@ -63,11 +63,7 @@ export type TableNodeType = Node<
highlightOverlappingTables?: boolean;
hasHighlightedCustomType?: boolean;
highlightTable?: boolean;
onStartRelationship?: (sourceTableId: string) => void;
isPendingRelationshipTarget?: boolean;
isDialogSource?: boolean;
isDialogTarget?: boolean;
isPendingRelationshipSource?: boolean;
isRelationshipCreatingTarget?: boolean;
},
'table'
>;
@@ -83,11 +79,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
highlightOverlappingTables,
hasHighlightedCustomType,
highlightTable,
onStartRelationship,
isPendingRelationshipTarget,
isDialogSource,
isDialogTarget,
isPendingRelationshipSource,
isRelationshipCreatingTarget,
},
}) => {
const { updateTable, relationships, readonly } = useChartDB();
@@ -100,7 +92,13 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
const [expanded, setExpanded] = useState(table.expanded ?? false);
const { t } = useTranslation();
const [isHovering, setIsHovering] = useState(false);
const { setEditTableModeTable, editTableModeTable } = useCanvas();
const {
setEditTableModeTable,
editTableModeTable,
setHoveringTableId,
showCreateRelationshipNode,
tempFloatingEdge,
} = useCanvas();
// Get edit mode state directly from context
const editTableMode = useMemo(
@@ -124,17 +122,6 @@ 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,
@@ -344,19 +331,22 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
editModeInitialFieldCount,
]);
const isPartOfCreatingRelationship = useMemo(
() =>
tempFloatingEdge?.sourceNodeId === id ||
(isRelationshipCreatingTarget &&
tempFloatingEdge?.targetNodeId === id) ||
isHovering,
[tempFloatingEdge, id, isRelationshipCreatingTarget, isHovering]
);
const tableClassName = useMemo(
() =>
cn(
'flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-sm transition-transform duration-300',
// 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',
selected || isTarget || isPartOfCreatingRelationship
? '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'
: '',
@@ -399,10 +389,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isDiffTableRemoved,
isTarget,
editTableMode,
isPendingRelationshipTarget,
isHovering,
isDialogSource,
isDialogTarget,
isPartOfCreatingRelationship,
]
);
@@ -424,64 +411,8 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
setEditTableModeTable(null);
}, [setEditTableModeTable]);
const startRelationshipCreation = useCallback(() => {
if (readonly) {
return;
}
// Check if we have a direct callback from canvas to open the dialog
if (onStartRelationship) {
onStartRelationship(table.id);
return;
}
// Fallback: Try to simulate the drag (keeping old implementation as fallback)
const handleId = `${TABLE_RELATIONSHIP_HANDLE_ID_PREFIX}${table.id}`;
const handle = document.querySelector(
`[data-handleid="${handleId}"]`
) as HTMLElement;
if (!handle) {
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, onStartRelationship]);
return (
<TableNodeContextMenu
table={table}
onStartRelationshipCreation={startRelationshipCreation}
>
<TableNodeContextMenu table={table}>
{editTableMode ? (
<TableEditMode
table={table}
@@ -496,11 +427,33 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
className={tableClassName}
onClick={(e) => {
if (e.detail === 2 && !readonly) {
e.stopPropagation();
enterEditTableMode();
} else if (e.detail === 1 && !readonly) {
// Handle single click
if (
isRelationshipCreatingTarget &&
tempFloatingEdge
) {
e.stopPropagation();
showCreateRelationshipNode({
sourceTableId:
tempFloatingEdge.sourceNodeId,
targetTableId: table.id,
x: e.clientX,
y: e.clientY,
});
}
}
}}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onMouseEnter={() => {
setIsHovering(true);
setHoveringTableId(table.id);
}}
onMouseLeave={() => {
setIsHovering(false);
setHoveringTableId(null);
}}
>
<NodeResizer
isVisible={focused}
@@ -510,33 +463,29 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
shouldResize={(event) => event.dy === 0}
handleClassName="!hidden"
/>
{/* Center handle for floating edge creation */}
{!readonly ? (
<Handle
id={`${TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX}${table.id}`}
type="source"
position={Position.Top}
className="!invisible !left-1/2 !top-1/2 !h-1 !w-1 !-translate-x-1/2 !-translate-y-1/2 !transform"
/>
) : null}
{/* Target handle covering entire table for floating edge creation */}
{!readonly ? (
<Handle
id={`${TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX}${table.id}`}
type="target"
position={Position.Top}
className="!absolute !left-0 !top-0 !h-full !w-full !transform-none !rounded-none !border-none !opacity-0"
isConnectable={isRelationshipCreatingTarget}
/>
) : null}
<TableNodeDependencyIndicator
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
@@ -660,12 +609,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
{visibleFields.map((field: DBField) => (
<TableNodeField
key={field.id}
focused={
focused &&
!isPendingRelationshipTarget &&
!isDialogSource &&
!isPendingRelationshipSource
}
focused={focused}
tableNodeId={id}
field={field}
highlighted={highlightedFieldIds.has(field.id)}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { type NodeProps, type Node, Handle, Position } from '@xyflow/react';
export const TEMP_CURSOR_NODE_ID = '__temp_cursor_node__';
export const TEMP_CURSOR_HANDLE_ID = '__temp-cursor-target__';
export type TempCursorNodeType = Node<
{
// Empty data object - this is just a cursor position marker
},
'temp-cursor'
>;
export const TempCursorNode: React.FC<NodeProps<TempCursorNodeType>> =
React.memo(() => {
// Invisible node that just serves as a connection point
return (
<div
style={{
width: 1,
height: 1,
opacity: 0,
pointerEvents: 'none',
}}
>
<Handle
id={TEMP_CURSOR_HANDLE_ID}
className="!invisible"
position={Position.Right}
type="target"
/>
</div>
);
});
TempCursorNode.displayName = 'TempCursorNode';

View File

@@ -0,0 +1,64 @@
import React from 'react';
import type { Edge, EdgeProps } from '@xyflow/react';
import { getSmoothStepPath, Position } from '@xyflow/react';
export const TEMP_FLOATING_EDGE_ID = '__temp_floating_edge__';
export type TempFloatingEdgeType = Edge<
{
// No relationship data - this is a temporary visual edge
},
'temp-floating-edge'
>;
export const TempFloatingEdge: React.FC<EdgeProps<TempFloatingEdgeType>> =
React.memo(
({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition = Position.Right,
targetPosition = Position.Left,
}) => {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 14,
});
return (
<g>
<path
id={id}
fill="none"
stroke="#ec4899"
strokeWidth={2}
strokeDasharray="5,5"
d={edgePath}
style={{
pointerEvents: 'none',
}}
/>
<circle
cx={targetX}
cy={targetY}
fill="#fff"
r={3}
stroke="#ec4899"
strokeWidth={1.5}
style={{
pointerEvents: 'none',
}}
/>
</g>
);
}
);
TempFloatingEdge.displayName = 'TempFloatingEdge';