position new created table in the top left corner

This commit is contained in:
KarimTamani
2025-05-24 17:48:49 +01:00
parent 94ff6ddf33
commit 057353156a
12 changed files with 430 additions and 343 deletions
-56
View File
@@ -1,56 +0,0 @@
import { TableType } from "@/lib/schemas/table-schema";
import { Node, useReactFlow } from "@xyflow/react";
import { useMemo } from "react";
const useGetOverlappingNodes = (): Set<string> => {
const { getNodes } = useReactFlow();
const nodes = getNodes();
const overlappingNodes: Set<string> = useMemo(() => {
const overlaps: Set<string> = new Set();
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
if (isNodesOverlapping(nodes[i], nodes[j])) {
overlaps.add(nodes[i].id);
overlaps.add(nodes[j].id);
}
}
}
return overlaps
}, [nodes]);
return overlappingNodes;
}
function isNodesOverlapping(nodeA: Node, nodeB: Node) {
const nodeAWidth: number = nodeA.measured?.width || parseInt(nodeA.style?.width as string) || 224;
const nodeAHeight: number = nodeA.measured?.height || (nodeA.data.table as TableType).fields.length * 32 + 36;
const nodeBWidth: number = nodeB.measured?.width || parseInt(nodeB.style?.width as string) || 224;
const nodeBHeight: number = nodeB.measured?.height || (nodeB.data.table as TableType).fields.length * 32 + 36;
const a = {
left: nodeA.position.x,
right: nodeA.position.x + nodeAWidth,
top: nodeA.position.y,
bottom: nodeA.position.y + nodeAHeight,
};
const b = {
left: nodeB.position.x,
right: nodeB.position.x + nodeBWidth,
top: nodeB.position.y,
bottom: nodeB.position.y + nodeBHeight,
};
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
export default useGetOverlappingNodes;
-30
View File
@@ -1,30 +0,0 @@
import { Edge, useReactFlow, useStore } from "@xyflow/react";
import { useEffect, useState } from "react";
import hash from 'object-hash';
const useGetRelatedEdges = (nodeId: string) => {
const [relatedEdges, setRelatedEdges] = useState<Edge[]>([]);
const { getEdges } = useReactFlow();
const edges = getEdges();
useEffect(() => {
const newRelatedEdges: Edge[] = edges.filter((edge: Edge) => edge.source == nodeId || edge.target == nodeId);
if (hash(newRelatedEdges) != hash(relatedEdges)) {
setRelatedEdges(newRelatedEdges)
}
}, [edges, nodeId])
return relatedEdges
}
export default useGetRelatedEdges;
+87
View File
@@ -0,0 +1,87 @@
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { areArraysEqual } from "@/utils/utils";
import { Edge, Node, useReactFlow } from "@xyflow/react";
import { useEffect, useMemo, useState } from "react";
/**
* Custom hook to manage and visually highlight edges in a React Flow diagram
* based on selected nodes and their relationships.
*/
const useHighlightedEdges = (nodes: Node[], relationships: RelationshipType[], edges: Edge[]) => {
const { setNodes, setEdges } = useReactFlow();
// Keeps track of currently selected node IDs
const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);
/**
* Effect to update selected node IDs when node selection changes.
* Prevents unnecessary state updates by comparing the new selection with the previous one.
*/
useEffect(() => {
const newSelectedNodesIds: string[] = nodes
.filter((node: Node) => node.selected)
.map((node: Node) => node.id);
// Only update state if the selected nodes have changed
if (areArraysEqual(newSelectedNodesIds, selectedNodeIds)) return;
setSelectedNodeIds(newSelectedNodesIds);
}, [nodes]);
/**
* Memoized list of edge IDs that are connected to selected nodes.
* This avoids recomputation unless `selectedNodeIds` or `relationships` change.
*/
const selectedEdgeIds: string[] = useMemo(() => {
return relationships
.filter((relationship: RelationshipType) =>
selectedNodeIds.includes(relationship.sourceTableId) ||
selectedNodeIds.includes(relationship.targetTableId)
)
.map((relationship: RelationshipType) => relationship.id);
}, [selectedNodeIds, relationships]);
/**
* Effect to update edge animation state based on selection.
* Edges that are connected to selected nodes will be animated.
*/
useEffect(() => {
setEdges((edges: any) => {
return edges.map((edge: any) => {
const selected: boolean = selectedEdgeIds.includes(edge.id);
// Only update edge if its animation state needs to change
return (edge.animated === selected)
? edge
: { ...edge, animated: selected } as Edge;
});
});
}, [selectedEdgeIds]);
/**
* Effect to update nodes with a list of their connected and animated/selected edges.
* This information can be used to visually emphasize connected edges in node components.
*/
useEffect(() => {
setNodes((nodes: any) => {
return nodes.map((node: any) => {
// Find edges that are either animated or selected and connected to this node
const newHighlightedEdges: Edge[] = edges.filter((edge: Edge) =>
(edge.animated || edge.selected) &&
(edge.source === node.id || edge.target === node.id)
);
// Add the highlighted edges to the node's data
return {
...node,
data: {
...node.data,
highlightedEdges: newHighlightedEdges
}
};
});
});
}, [edges]);
};
export default useHighlightedEdges;
+153
View File
@@ -0,0 +1,153 @@
import { TableType } from "@/lib/schemas/table-schema";
import { areArraysEqual } from "@/utils/utils";
import { Node, useReactFlow } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useState } from "react";
// Return type of the hook
type UseOverlapingType = {
isOverlapping: boolean, // Indicates if any nodes are overlapping
puls: () => void // Triggers a short visual pulse effect for overlapping nodes
}
/**
* Custom hook to detect overlapping nodes in a React Flow diagram,
* annotate them with metadata (`overlapping`, `pulsing`),
* and provide a pulse trigger for visual feedback.
*/
const useOverlappingNodes = (nodes: Node[]): UseOverlapingType => {
const [isPulsing, setIsPulsing] = useState<boolean>(false);
const { setNodes } = useReactFlow();
/**
* Triggers a brief "pulse" effect (e.g. for animations or styles)
* by toggling a boolean flag for a short time.
*/
const puls = useCallback(() => {
setIsPulsing(true);
setTimeout(() => setIsPulsing(false), 200);
}, []);
/**
* Compute which nodes are currently overlapping.
* This is memoized to avoid recalculating unless nodes change.
*/
const overlappingNodes: Set<string> = useMemo(() => {
const overlaps: Set<string> = new Set();
// Compare every pair of nodes
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
if (isNodesOverlapping(nodes[i], nodes[j])) {
overlaps.add(nodes[i].id);
overlaps.add(nodes[j].id);
}
}
}
return overlaps;
}, [nodes]);
/**
* When overlapping nodes change, update each nodes `data.overlapping` property accordingly.
* Avoids unnecessary updates using deep comparison.
*/
useEffect(() => {
const overlappingIds: string[] = Array.from(overlappingNodes);
const previousOverlappingNodesIds: string[] = nodes
.filter((node: Node) => node.data.overlapping)
.map((node: Node) => node.id);
if (!areArraysEqual(previousOverlappingNodesIds, overlappingIds)) {
setNodes((nodes: any) => {
return nodes.map((node: any) => {
const isOverlaped: boolean = overlappingIds.includes(node.id);
if (isOverlaped === node.data.overlapping)
return node; // No change needed
else
return {
...node,
data: {
...node.data,
overlapping: isOverlaped
}
};
});
});
}
}, [overlappingNodes]);
/**
* When `isPulsing` is active, apply a `pulsing` property to overlapping nodes
* to trigger animations or visual indicators in the UI.
*/
useEffect(() => {
setNodes((nodes: any) => {
return nodes.map((node: any) => {
if (!node.data.overlapping)
return node;
return {
...node,
data: {
...node.data,
pulsing: isPulsing
}
};
});
});
}, [isPulsing]);
// True if any overlapping nodes exist
const isOverlapping: boolean = overlappingNodes.size > 0;
// Return memoized object to avoid unnecessary rerenders
return useMemo(() => ({
puls,
isOverlapping
}), [isOverlapping, puls]);
};
export default useOverlappingNodes;
/**
* Helper function to check whether two nodes are overlapping
* Uses their position and dimensions to determine intersection.
*/
function isNodesOverlapping(nodeA: Node, nodeB: Node) {
const nodeAWidth: number =
nodeA.measured?.width ||
parseInt(nodeA.style?.width as string) ||
224; // Default fallback width
const nodeAHeight: number =
nodeA.measured?.height ||
(nodeA.data.table as TableType).fields.length * 32 + 36; // Estimate height based on number of fields
const nodeBWidth: number =
nodeB.measured?.width ||
parseInt(nodeB.style?.width as string) ||
224;
const nodeBHeight: number =
nodeB.measured?.height ||
(nodeB.data.table as TableType).fields.length * 32 + 36;
// Bounding box of node A
const a = {
left: nodeA.position.x,
right: nodeA.position.x + nodeAWidth,
top: nodeA.position.y,
bottom: nodeA.position.y + nodeAHeight,
};
// Bounding box of node B
const b = {
left: nodeB.position.x,
right: nodeB.position.x + nodeBWidth,
top: nodeB.position.y,
bottom: nodeB.position.y + nodeBHeight,
};
// Check if the bounding boxes intersect
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
+58 -134
View File
@@ -1,52 +1,66 @@
// Importing necessary types and hooks from React Flow (XYFlow)
import {
addEdge, Background, Connection, Controls, EdgeChange,
EdgeRemoveChange, NodeChange, NodePositionChange,
NodeRemoveChange, OnEdgesChange, OnNodesChange,
ReactFlow, useEdgesState, useNodesState, useReactFlow
} from "@xyflow/react";
import { addEdge, applyNodeChanges, Background, Connection, Controls, Edge, EdgeChange, EdgeRemoveChange, MiniMap, Node, NodeChange, NodePositionChange, NodeRemoveChange, NodeSelectionChange, OnEdgesChange, OnNodesChange, ReactFlow, useEdgesState, useNodesState, useReactFlow } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useState } from "react";
// React built-ins
import { useCallback, useMemo } from "react";
// Custom components and styles
import Table from "./table/table";
import '@xyflow/react/dist/style.css';
import Relationship from "./table/relationship";
import DBController from "./db-controller/db-controller";
import { TableInsertType } from "@/lib/schemas/table-schema";
// Custom context providers and hooks
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { useTableToNode } from "@/hooks/use-table-to-node";
import { useRelationshipToEdge } from "@/hooks/use-relationship-to-edge";
import { RelationshipInsertType, RelationshipType } from "@/lib/schemas/relationship-schema";
import { RelationshipInsertType } from "@/lib/schemas/relationship-schema";
// Utils and constants
import { v4 } from "uuid";
import { TARGET_PREFIX } from "./table/field";
import CardinalityMarker from "@/components/cardinality-marker/cardinality-marker";
import { areArraysEqual } from "@/utils/utils";
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/tooltip";
import { addToast, Button, Image } from "@heroui/react";
import { AlertTriangle, LayoutGrid } from "lucide-react";
import { addToast, Button } from "@heroui/react";
import { AlertTriangle } from "lucide-react";
import { adjustTablesPositions } from "@/utils/tables";
import DatabaseControlButtons from "./database-control-buttons";
import useGetOverlappingNodes from "@/hooks/use-get-overlapping-nodes";
import { FieldType } from "@/lib/schemas/field-schema";
import useHighlightedEdges from "@/hooks/use-highlighted-edges";
import useOverlappingNodes from "@/hooks/use-overlapping-nodes";
// Main functional component
const DatabasePage: React.FC<never> = () => {
// Extract database state and operations
const { database, getField } = useDatabase();
const { updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabaseOperations();
// Node and edge state hooks
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// Diagram-related state (e.g. connection in progress)
const { setIsConnectionInProgress } = useDiagramOps();
const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);
const [isTableOverlappingPulsing, setIsTableOverlappingPulsing] = useState<boolean>(false);
// Destructure tables and relationships from database
const { tables, relationships } = database;
// Hook to allow zooming and centering the diagram
const { fitView } = useReactFlow();
// Define custom node and edge types
const nodeTypes = useMemo(() => ({ table: Table }), []);
const edgeTypes = useMemo(() => ({ 'relationship-edge': Relationship }), []);
useTableToNode(tables);
useRelationshipToEdge(relationships);
// Called when a connection is made between fields
const onConnect = useCallback((connection: Connection) => {
const sourceFieldId: string | undefined = (connection.sourceHandle as string).split("_").pop();
const targetFieldId: string = (connection.targetHandle as string).replace(TARGET_PREFIX, "");
@@ -54,9 +68,8 @@ const DatabasePage: React.FC<never> = () => {
const sourceField: FieldType | undefined = getField(connection.source, sourceFieldId as string);
const targetField: FieldType | undefined = getField(connection.target, targetFieldId);
// Check if both fields have the same type (valid relationship)
if (sourceField?.typeId == targetField?.typeId) {
createRelationship({
id: v4(),
sourceTableId: connection.source,
@@ -65,28 +78,29 @@ const DatabasePage: React.FC<never> = () => {
targetFieldId
} as RelationshipInsertType);
// Add edge to the diagram
setEdges((eds) => addEdge(connection, eds));
} else {
// Show error toast if invalid relationship
addToast({
title: "Invalid Relationship",
description: "Relationship should be between primary key and foreign key of the same time",
color: "danger",
})
});
}
setIsConnectionInProgress(false);
setIsConnectionInProgress(false);
}, [database]);
// Called when nodes are updated (position changes or removed)
const handleNodesChanges: OnNodesChange<never> = useCallback(async (changes: NodeChange<never>[]) => {
const nodePositionChanges: NodePositionChange[] = changes.filter((change: NodeChange) =>
change.type == "position" &&
!change.dragging
change.type == "position" && !change.dragging
) as NodePositionChange[];
const nodeRemoveChanges: NodeRemoveChange[] = changes.filter((change: NodeChange) => change.type == "remove");
// Save new positions to the database
if (nodePositionChanges.length > 0)
await updateTablePositions(nodePositionChanges.map((change: NodePositionChange) => ({
id: change.id,
@@ -94,140 +108,50 @@ const DatabasePage: React.FC<never> = () => {
posY: change.position?.y
} as TableInsertType)));
// Delete tables if removed
if (nodeRemoveChanges.length > 0)
deleteMultiTables(nodeRemoveChanges.map((change: NodeRemoveChange) => change.id));
return onNodesChange(changes);
}, [onNodesChange]);
// Called when edges (relationships) change
const handleEdgeChanges: OnEdgesChange<any> = useCallback((changes: EdgeChange<any>[]) => {
const edgeRemoveChanges: EdgeRemoveChange[] = changes.filter((change: EdgeChange) => change.type == "remove") as EdgeRemoveChange[];
// Delete relationships from database
if (edgeRemoveChanges.length > 0) {
deleteMultiRelationships(edgeRemoveChanges.map((change: EdgeRemoveChange) => change.id))
deleteMultiRelationships(edgeRemoveChanges.map((change: EdgeRemoveChange) => change.id));
}
return onEdgesChange(changes as EdgeChange<never>[]);
}, [onEdgesChange]);
useEffect(() => {
const newSelectedNodesIds: string[] = nodes.filter((node: Node) => node.selected).map((node: Node) => node.id)
if (areArraysEqual(newSelectedNodesIds, selectedNodeIds)) return; setSelectedNodeIds(newSelectedNodesIds);
}, [nodes]);
const selectedEdgeIds: string[] = useMemo(() => {
return relationships.filter((relationship: RelationshipType) =>
selectedNodeIds.includes(relationship.sourceTableId) ||
selectedNodeIds.includes(relationship.targetTableId)
).map((relationship: RelationshipType) => relationship.id);
}, [selectedNodeIds, relationships]);
useEffect(() => {
setEdges((edges: any) => {
return edges.map((edge: any) => {
const selected: boolean = selectedEdgeIds.includes(edge.id);
return (edge.animated == selected) ? edge : { ...edge, animated: selected } as Edge;
})
});
}, [selectedEdgeIds]);
useEffect(() => {
setNodes((nodes: any) => {
return nodes.map((node: any) => {
const newHighlitedEdges: Edge[] = edges.filter((edge: Edge) =>
(edge.animated || edge.selected)
&& (edge.source == node.id || edge.target == node.id)
);
return {
...node,
data: {
...node.data,
highlightedEdges: newHighlitedEdges
}
}
})
})
}, [edges]);
// When user starts connecting fields
const onConnectStart = useCallback(() => {
setIsConnectionInProgress(true);
}, []);
// When user ends connecting fields
const onConnectEnd = useCallback(() => {
setIsConnectionInProgress(false);
}, []);
// Automatically reposition tables to avoid overlap and fit the view
const adjustPositions = useCallback(async () => {
updateTablePositions(await adjustTablesPositions(nodes, relationships));
setTimeout(() => {
fitView({
duration: 500
})
}, 300)
});
}, 300);
}, [relationships, nodes]);
const overlappingNodes = useGetOverlappingNodes();
useEffect(() => {
const overlappingIds = Array.from(overlappingNodes);
const previousOverlappingNodes = nodes.filter((node: Node) => node.data.overlapping);
if (!areArraysEqual(previousOverlappingNodes, overlappingIds)) {
setNodes((nodes: any) => {
return nodes.map((node: any) => {
const isOverlaped: boolean = overlappingIds.includes(node.id);
if (isOverlaped == node.data.overlapping)
return node;
else
return {
...node,
data: {
...node.data,
overlapping: isOverlaped
}
}
})
})
}
}, [overlappingNodes]);
useEffect(() => {
setNodes((nodes: any) => {
return nodes.map((node: any) => {
if (!node.data.overlapping)
return node;
else
return {
...node,
data: {
...node.data,
pulsing: isTableOverlappingPulsing
}
}
})
})
}, [isTableOverlappingPulsing])
const pulsOverlappingTables = useCallback(() => {
setIsTableOverlappingPulsing(true);
setTimeout(() => setIsTableOverlappingPulsing(false), 200);
}, [])
// Convert tables and relationships into flow elements
useTableToNode(tables);
useRelationshipToEdge(relationships);
useHighlightedEdges(nodes, relationships, edges);
const { isOverlapping, puls } = useOverlappingNodes(nodes);
return (
@@ -275,7 +199,7 @@ const DatabasePage: React.FC<never> = () => {
className="absolute left-[12px] top-[64px] "
>
{
overlappingNodes.size > 0 &&
isOverlapping &&
<Tooltip>
<TooltipTrigger asChild>
<span>
@@ -285,7 +209,7 @@ const DatabasePage: React.FC<never> = () => {
isIconOnly
color="danger"
className="size-8 p-1 "
onPressEnd={pulsOverlappingTables}
onPressEnd={puls}
>
<AlertTriangle className="size-4 text-white" />
</Button>
@@ -9,30 +9,47 @@ import { useDatabase, useDatabaseOperations } from "@/providers/database-provide
import { TableInsertType, TableType } from "@/lib/schemas/table-schema";
import { v4 } from "uuid";
import { useDiagram } from "@/providers/diagram-provider/diagram-provider";
import { useViewport } from "@xyflow/react";
interface Props { }
const PADDING_X = 40 ;
const PADDING_Y = 80 ;
const TablesController: React.FC<Props> = ({ }) => {
const { database } = useDatabase();
const {createTable} = useDatabaseOperations() ;
const { tables } = database ;
const { createTable } = useDatabaseOperations();
const { tables } = database;
const viewport = useViewport();
const { t } = useTranslation();
const [selectedTable, setSelectedTable] = useState(new Set([]));
const { focusedTableId } = useDiagram();
const addNewTable = useCallback(async () => {
const newTableId: string = v4();
const { x, y, zoom } = viewport;
// Convert screen (0,0) to flow coordinates using viewport values
const posX = -x / zoom + (PADDING_X / zoom);
const posY = -y / zoom + (PADDING_Y / zoom);
await createTable({
id: newTableId,
name: `table_${tables.length + 1}`,
posX ,
posY ,
fields : [{
id : v4() ,
name : "id" ,
isPrimary : true ,
unique : true
}]
} as TableInsertType);
setSelectedTable(new Set([newTableId]) as any);
}, [tables]);
}, [tables, viewport]);
useEffect(() => {
+8 -8
View File
@@ -1,10 +1,10 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/tooltip";
import { FieldType } from "@/lib/schemas/field-schema";
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { useDiagram, useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
import { Button, cn, select } from "@heroui/react";
import { Handle, Position, useConnection } from "@xyflow/react";
import { Check, KeyRound, Trash, Trash2 } from "lucide-react";
import { useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
import { Button, cn } from "@heroui/react";
import { Handle, Position } from "@xyflow/react";
import { Check, KeyRound, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import hash from 'object-hash';
@@ -45,11 +45,11 @@ const Field: React.FC<Props> = (props) => {
setEditMode(false);
}, [fieldName])
return (
<div className={cn(
"group relative flex h-8 items-center justify-between gap-1 border-t border-default dark:border-default/5 px-3 text-sm last:rounded-b-[6px] hover:bg-slate-100 dark:hover:bg-primary-500 transition-all duration-200 ease-in-out",
"group relative flex h-8 items-center justify-between gap-1 border-t border-default dark:border-default/5 px-3 text-sm last:rounded-b-[6px] hover:bg-slate-100 dark:hover:bg-primary/5 transition-all duration-200 ease-in-out",
highlight ? "bg-primary/5" : ""
)}>
{
+13 -13
View File
@@ -1,6 +1,6 @@
import { Edge, Node, NodeProps, useConnection, useStore } from "@xyflow/react";
import { Edge, Node, NodeProps } from "@xyflow/react";
import hash from 'object-hash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Card, cn, } from "@heroui/react";
@@ -14,21 +14,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/to
import FieldComponent from "./field";
import { FieldType } from "@/lib/schemas/field-schema";
import { TableInsertType, TableType } from "@/lib/schemas/table-schema";
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { useTranslation } from "react-i18next";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
import useGetRelatedEdges from "@/hooks/use-get-related-edges";
export type TableProps = Node<{
table: TableType,
overlapping?: boolean,
pulsing?: boolean,
highlightedEdges : Edge[]
highlightedEdges: Edge[]
}>
const Table: React.FC<NodeProps<TableProps>> = ({ selected, data: { table, overlapping = false, pulsing = false , highlightedEdges = [] } }) => {
const Table: React.FC<NodeProps<TableProps>> = ({ selected, data: { table, overlapping = false, pulsing = false, highlightedEdges = [] } }) => {
const [editMode, setEditMode] = useState<boolean>(false);
@@ -50,9 +51,9 @@ const Table: React.FC<NodeProps<TableProps>> = ({ selected, data: { table, overl
const focus = useCallback(() => {
focusOnTable(table.id, false);
}, [table])
const fields: React.ReactNode[] = useMemo(() => {
return table.fields.map((field: FieldType) => {
const highlight: boolean = highlightedEdges.find((edge: any) =>
@@ -67,15 +68,15 @@ const Table: React.FC<NodeProps<TableProps>> = ({ selected, data: { table, overl
/>)
})
}, [table.fields, selected, highlightedEdges]);
return (
return (
<Card className={cn(
"w-full h-full bg-background rounded-lg noselect overflow-visible dark:bg-default-900",
selected
? 'ring-2 ring-primary'
? 'ring-2 ring-offset-1 ring-primary dark:ring-offset-default-900'
: '',
overlapping
? 'ring-2 dark:ring-offset-default-900 ring-danger ring-offset-1 scale-105 shadow-danger '
@@ -164,6 +165,5 @@ const Table: React.FC<NodeProps<TableProps>> = ({ selected, data: { table, overl
export default React.memo(Table, (previousState: any, newState: any) => {
const previousStateHash: string = hash(previousState.data);
const newStateHash: string = hash(newState.data);
return previousStateHash == newStateHash && previousState.selected == newState.selected;
})
@@ -26,13 +26,13 @@ interface DatabaseOperationsContextType {
createTable: (table: TableInsertType) => Promise<void>,
editTable: (table: TableInsertType) => Promise<QueryResult>,
deleteTable: (id: string) => Promise<void>,
updateTablePositions: (tables: TableInsertType[]) => Promise<QueryResult>,
updateTablePositions: (tables: TableInsertType[]) => Promise<void>,
deleteMultiTables: (ids: string[]) => Promise<QueryResult>
// field operations
createField: (field: FieldInsertType) => Promise<QueryResult>,
editField: (field: FieldInsertType) => Promise<QueryResult>,
deleteField: (id: string) => Promise<void>,
orderTableFields: (fields: FieldType[]) => Promise<QueryResult>,
orderTableFields: (fields: FieldType[]) => Promise<void>,
// relationship operations
createRelationship: (relationship: RelationshipInsertType) => Promise<QueryResult>,
editRelationship: (relationship: RelationshipInsertType) => Promise<QueryResult>,
@@ -1,7 +1,6 @@
import { DatabaseDataContext, DatabaseOperationsContext } from "./database-context";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { db, powerSyncDb } from "../sync-provider/sync-provider";
import { db } from "../sync-provider/sync-provider";
import { TableInsertType, tables, TableType } from "@/lib/schemas/table-schema";
import { useQuery } from "@powersync/react";
import { toCompilableQuery } from "@powersync/drizzle-driver";
@@ -14,21 +13,23 @@ import { DBDiffOperation } from "@/utils/database";
import { DatabaseType } from "@/lib/schemas/database-schema";
import { getTimestamp } from "@/utils/utils";
interface Props { children: React.ReactNode }
const DatabaseProvider: React.FC<Props> = ({ children }) => {
const [currentDatabaseId, setCurrentDatabaseId] = useState<string | undefined>(undefined);
// Fetch all databases
const { data: databases, isLoading: loadingDatabases } = useQuery(toCompilableQuery(
db.query.databases.findMany()
));
// Fetch all data types
const { data: data_types, isLoading: loadingDataTypes } = useQuery(toCompilableQuery(
db.query.data_types.findMany()
));
// Fetch the current database with nested tables, fields, and relationships
let { data: database, isLoading: loadingCurrentDatabase } = useQuery(
toCompilableQuery(
db.query.databases.findFirst({
@@ -59,9 +60,11 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
)
);
// Normalize result to single object
if (database.length == 1)
database = database[0] as any;
// Auto-select first database if none is selected
useEffect(() => {
if (databases.length > 0 && !currentDatabaseId) {
setCurrentDatabaseId(databases[0].id);
@@ -70,21 +73,24 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
const isLoading: boolean = loadingDataTypes || loadingDatabases || loadingCurrentDatabase;
// CRUD operations for Tables
const createTable = useCallback(async (table: TableInsertType): Promise<void> => {
if (currentDatabaseId) {
await db.transaction(async (tx) => {
await tx.insert(tables).values({
...table,
databaseId: currentDatabaseId as string,
createdAt: table.createdAt ? table.createdAt : getTimestamp()
databaseId: currentDatabaseId,
createdAt: table.createdAt || getTimestamp()
});
if (table.fields) {
await tx.insert(fields).values(table.fields);
await tx.insert(fields).values(
table.fields.map((field: FieldInsertType) => ({ ...field, tableId: table.id }))
);
}
})
} else
} else {
throw Error("No Database selected");
}
}, [db, currentDatabaseId]);
const editTable = useCallback(async (table: TableInsertType): Promise<QueryResult> => {
@@ -99,6 +105,7 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
return await db.delete(tables).where(inArray(tables.id, ids));
}, [db]);
// CRUD operations for Fields
const createField = useCallback(async (field: FieldInsertType): Promise<QueryResult> => {
return await db.insert(fields).values(field);
}, [db]);
@@ -107,6 +114,7 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
return await db.update(fields).set(field).where(eq(fields.id, field.id));
}, [db]);
// Delete field and its related relationships
const deleteField = useCallback(async (id: string): Promise<void> => {
return await db.transaction(async (tx) => {
await tx.delete(relationships).where(or(eq(relationships.sourceFieldId, id), eq(relationships.targetFieldId, id)));
@@ -114,37 +122,31 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
})
}, [db]);
// Helper to find a field by table and field ID
const getField = useCallback((tableId: string, id: string) => {
const table: TableType | undefined = (database as any).tables.find((table: TableType) => table.id == tableId);
if (table)
return table.fields.find((field: FieldType) => field.id == id);
}, [database])
const orderTableFields = useCallback(async (fieldsList: FieldType[]): Promise<QueryResult> => {
const caseStatements = fieldsList
.map((field, index) => `WHEN '${field.id}' THEN ${index}`)
.join('\n ');
const ids = fieldsList.map(u => `'${u.id}'`).join(',\n ');
const sql = `
UPDATE fields
SET sequence = CASE id
${caseStatements}
END
WHERE id IN (
${ids}
);`;
return await powerSyncDb.execute(sql);
}, [powerSyncDb]);
// Reorder fields in a table
const orderTableFields = useCallback(async (fieldsList: FieldType[]): Promise<void> => {
return await db.transaction(async (tx) => {
for (let index = 0; index < fieldsList.length; index++) {
await tx.update(fields).set({
sequence: index
}).where(eq(fields.id, fieldsList[index].id))
}
})
}, [db]);
// CRUD operations for Relationships
const createRelationship = useCallback(async (relationship: RelationshipInsertType): Promise<QueryResult> => {
if (currentDatabaseId) {
return await db.insert(relationships).values({
...relationship,
databaseId: currentDatabaseId,
createdAt: relationship.createdAt ? relationship.createdAt : getTimestamp()
createdAt: relationship.createdAt || getTimestamp()
});
}
throw Error("No database selected");
@@ -162,45 +164,29 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
return await db.delete(relationships).where(inArray(relationships.id, ids));
}, [db]);
const updateTablePositions = useCallback(async (tableList: TableInsertType[]): Promise<QueryResult> => {
const posXCases = tableList
.map(table => `WHEN '${table.id}' THEN ${table.posX}`)
.join('\n ');
const posYCases = tableList
.map(table => `WHEN '${table.id}' THEN ${table.posY}`)
.join('\n ');
const ids = tableList.map(table => `'${table.id}'`).join(',\n ');
const sql = `
UPDATE tables
SET
posX = CASE id
${posXCases}
END,
posY = CASE id
${posYCases}
END
WHERE id IN (
${ids}
);`;
return await powerSyncDb.execute(sql);
}, [powerSyncDb]);
// Update table positions (for UI layout)
const updateTablePositions = useCallback(async (tableList: TableInsertType[]): Promise<void> => {
return await db.transaction(async (tx) => {
for (const table of tableList) {
await tx.update(tables).set({
posX: (table as any).posX,
posY: (table as any).posY
}).where(eq(tables.id, (table as any).id))
}
})
}, [db]);
// Apply a list of diff operations to sync database
const executeDbDiffOps = useCallback(async (operations: DBDiffOperation[]) => {
try {
await db.transaction(async (tx) => {
for (const operation of operations) {
if (operation.type == "CREATE_TABLE") {
await tx.insert(tables).values(operation.table);
if (operation.table.fields && Object.values(operation.table.fields).length > 0)
await tx.insert(fields).values(Object.values(operation.table.fields));
}
else if (operation.type === "UPDATE_TABLE") {
} else if (operation.type === "UPDATE_TABLE") {
await tx.update(tables).set(operation.changes).where(eq(tables.id, operation.tableId));
} else if (operation.type === "DELETE_TABLE") {
@@ -227,14 +213,11 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
}
}
})
} catch (error) {
} catch (error) {
throw error
}
}, [db]);
const databaseOpsValue = useMemo(() => ({
createTable,
editTable,
@@ -245,7 +228,6 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
editField,
deleteField,
orderTableFields,
createRelationship,
editRelationship,
deleteRelationship,
@@ -261,7 +243,6 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
editField,
deleteField,
orderTableFields,
createRelationship,
editRelationship,
deleteRelationship,
@@ -270,7 +251,6 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
]);
return (
<DatabaseDataContext.Provider value={{
data_types,
database: database as unknown as DatabaseType,
@@ -278,7 +258,6 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
getField,
}}>
<DatabaseOperationsContext.Provider value={databaseOpsValue}>
{
!isLoading && database &&
<DatabaseHistoryProvider>
@@ -294,6 +273,3 @@ export const useDatabase = () => useContext(DatabaseDataContext);
export const useDatabaseOperations = () => useContext(DatabaseOperationsContext);
export default DatabaseProvider;
+40 -25
View File
@@ -1,11 +1,11 @@
// Importing types from schema definitions
import { DatabaseType } from "@/lib/schemas/database-schema";
import { FieldType } from "@/lib/schemas/field-schema";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { TableType } from "@/lib/schemas/table-schema";
import { excludeFields } from "./utils";
// Define the possible operations that can be performed when diffing databases
export type DBDiffOperation =
| { type: 'CREATE_TABLE'; table: TableType }
| { type: 'DELETE_TABLE'; tableId: string }
@@ -17,26 +17,30 @@ export type DBDiffOperation =
| { type: 'DELETE_RELATIONSHIP'; relationshipId: string }
| { type: 'UPDATE_RELATIONSHIP'; relationshipId: string; changes: Partial<RelationshipType> };
/**
* Convert a list of low-level JSON patch operations into higher-level database diff operations
*/
export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
const operations: DBDiffOperation[] = [];
// Track changes for tables, fields, and relationships
const tableChanges: Record<string, Partial<TableType>> = {};
const fieldChanges: Record<string, Record<string, Partial<FieldType>>> = {};
const fieldCreates: Record<string, FieldType[]> = {};
const fieldDeletes: Record<string, string[]> = {};
const relationshipChanges: Record<string, Partial<RelationshipType>> = {};
const relationshipCreates: RelationshipType[] = [];
const relationshipDeletes: string[] = [];
// Loop through each patch operation
for (const op of patch) {
const parts = op.path.split('/').filter(Boolean);
const parts = op.path.split('/').filter(Boolean); // Split JSON path into parts
// Handle table-related changes
if (parts[0] == "tables") {
const tableId = parts[1];
// Whole table operations (add/delete table)
if (parts.length === 2) {
if (op.op === 'add') {
operations.push({ type: 'CREATE_TABLE', table: op.value });
@@ -45,7 +49,7 @@ export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
}
}
// Field-related operations inside a table
else if (parts[2] === 'fields') {
const fieldId = parts[3];
@@ -63,25 +67,29 @@ export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
}
}
// Updating table attributes (e.g., name, position, etc.)
else if (op.op === 'replace') {
const attr = parts[2];
tableChanges[tableId] ??= {};
tableChanges[tableId][attr as keyof TableType] = op.value;
}
}
else if (parts[0] === 'relationships') {
// Handle relationship-related changes
else if (parts[0] === 'relationships') {
const relationshipId = parts[1];
// Create or delete the entire relationship
if (parts.length === 2) {
if (op.op === 'add') {
relationshipCreates.push(op.value);
} else if (op.op === 'remove') {
relationshipDeletes.push(relationshipId);
}
} else if (op.op === 'replace') {
}
// Update relationship properties (e.g., type, constraints)
else if (op.op === 'replace') {
const attr = parts.slice(2).join('/');
relationshipChanges[relationshipId] ??= {};
relationshipChanges[relationshipId][attr as keyof RelationshipType] = op.value;
@@ -89,7 +97,7 @@ export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
}
}
// Push table update operations
for (const [tableId, changes] of Object.entries(tableChanges)) {
operations.push({
type: 'UPDATE_TABLE',
@@ -97,7 +105,8 @@ export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
changes
});
}
// Push field update operations
for (const [tableId, fields] of Object.entries(fieldChanges)) {
for (const [fieldId, changes] of Object.entries(fields)) {
operations.push({
@@ -108,7 +117,8 @@ export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
});
}
}
// Push field creation operations
for (const [tableId, fields] of Object.entries(fieldCreates)) {
for (const field of fields) {
operations.push({
@@ -118,7 +128,8 @@ export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
});
}
}
// Push field deletion operations
for (const [tableId, fieldIds] of Object.entries(fieldDeletes)) {
for (const fieldId of fieldIds) {
operations.push({
@@ -128,23 +139,31 @@ export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
});
}
}
// Push relationship creation operations
for (const relationship of relationshipCreates) {
operations.push({ type: 'CREATE_RELATIONSHIP', relationship });
}
// Push relationship deletion operations
for (const relationshipId of relationshipDeletes) {
operations.push({ type: 'DELETE_RELATIONSHIP', relationshipId });
}
// Push relationship update operations
for (const [relationshipId, changes] of Object.entries(relationshipChanges)) {
operations.push({ type: 'UPDATE_RELATIONSHIP', relationshipId, changes });
}
return operations;
}
/**
* Convert a Database object to a normalized form:
* - Convert arrays to maps keyed by ID for easier access
* - Exclude some fields from relationships to avoid circular references
*/
export function normalizeDatabase(db: DatabaseType): any {
return {
...db,
tables: Object.fromEntries(
@@ -159,16 +178,12 @@ export function normalizeDatabase(db: DatabaseType): any {
])
),
relationships: Object.fromEntries(
db.relationships.map((rel : RelationshipType) => [
rel.id,
excludeFields(rel , {
root : ["sourceField" , "targetField" , "sourceTable" , "targetTable" ]
db.relationships.map((rel: RelationshipType) => [
rel.id,
excludeFields(rel, {
root: ["sourceField", "targetField", "sourceTable", "targetTable"]
})
])
)
};
}
+4 -3
View File
@@ -42,8 +42,8 @@ const adjustTablesPositions = async (
const node = layoutedGraph?.children?.find((n) => n.id === table.id);
if (node) {
// ELK positions are top-left, adjust to center like before
table.posX = node.x || 0 + (node.width / 2) + 112; // 112 = half your fixed width
table.posY = node.y || 0 + (node.height / 2) + 75; // 75 = half your fixed height
table.posX = node.x || 0 + (node.width / 2) + 112;
table.posY = node.y || 0 + (node.height / 2) + 75;
}
});
@@ -89,6 +89,7 @@ const getDefaultTableOverlapping = (table: TableType, tables: TableType[]): bool
export {
adjustTablesPositions,
getDefaultTableOverlapping
getDefaultTableOverlapping ,
isTablesOverlapping
}