mirror of
https://github.com/chartdb/chartdb.git
synced 2025-11-02 13:03:17 +00:00
Compare commits
4 Commits
jf/add_rea
...
619cdc564c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
619cdc564c | ||
|
|
459698b5d0 | ||
|
|
7ad0e7712d | ||
|
|
34475add32 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chartdb",
|
||||
"private": true,
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}</>;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user