From 057353156a9acc46e074c3890e905997f7cde33f Mon Sep 17 00:00:00 2001 From: KarimTamani Date: Sat, 24 May 2025 17:48:49 +0100 Subject: [PATCH] position new created table in the top left corner --- src/hooks/use-get-overlapping-nodes.tsx | 56 ----- src/hooks/use-get-related-edges.tsx | 30 --- src/hooks/use-highlighted-edges.tsx | 87 ++++++++ src/hooks/use-overlapping-nodes.tsx | 153 ++++++++++++++ src/pages/database/database-page.tsx | 192 ++++++------------ .../tables-controller/tables-controller.tsx | 27 ++- src/pages/database/table/field.tsx | 16 +- src/pages/database/table/table.tsx | 26 +-- .../database-provider/database-context.ts | 4 +- .../database-provider/database-provider.tsx | 110 ++++------ src/utils/database.ts | 65 +++--- src/utils/tables.ts | 7 +- 12 files changed, 430 insertions(+), 343 deletions(-) delete mode 100644 src/hooks/use-get-overlapping-nodes.tsx delete mode 100644 src/hooks/use-get-related-edges.tsx create mode 100644 src/hooks/use-highlighted-edges.tsx create mode 100644 src/hooks/use-overlapping-nodes.tsx diff --git a/src/hooks/use-get-overlapping-nodes.tsx b/src/hooks/use-get-overlapping-nodes.tsx deleted file mode 100644 index f20b365..0000000 --- a/src/hooks/use-get-overlapping-nodes.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { TableType } from "@/lib/schemas/table-schema"; -import { Node, useReactFlow } from "@xyflow/react"; -import { useMemo } from "react"; - -const useGetOverlappingNodes = (): Set => { - const { getNodes } = useReactFlow(); - const nodes = getNodes(); - - const overlappingNodes: Set = useMemo(() => { - const overlaps: Set = 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; \ No newline at end of file diff --git a/src/hooks/use-get-related-edges.tsx b/src/hooks/use-get-related-edges.tsx deleted file mode 100644 index 23cc5a6..0000000 --- a/src/hooks/use-get-related-edges.tsx +++ /dev/null @@ -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([]); - 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; \ No newline at end of file diff --git a/src/hooks/use-highlighted-edges.tsx b/src/hooks/use-highlighted-edges.tsx new file mode 100644 index 0000000..e61ede5 --- /dev/null +++ b/src/hooks/use-highlighted-edges.tsx @@ -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([]); + + /** + * 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; diff --git a/src/hooks/use-overlapping-nodes.tsx b/src/hooks/use-overlapping-nodes.tsx new file mode 100644 index 0000000..7b78249 --- /dev/null +++ b/src/hooks/use-overlapping-nodes.tsx @@ -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(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 = useMemo(() => { + const overlaps: Set = 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 node’s `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); +} diff --git a/src/pages/database/database-page.tsx b/src/pages/database/database-page.tsx index f35fc50..9a69e1b 100644 --- a/src/pages/database/database-page.tsx +++ b/src/pages/database/database-page.tsx @@ -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 = () => { + // 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([]); - const [isTableOverlappingPulsing, setIsTableOverlappingPulsing] = useState(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 = () => { 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 = () => { 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 = useCallback(async (changes: NodeChange[]) => { - - 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 = () => { 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 = useCallback((changes: EdgeChange[]) => { - 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[]); }, [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 = () => { className="absolute left-[12px] top-[64px] " > { - overlappingNodes.size > 0 && + isOverlapping && @@ -285,7 +209,7 @@ const DatabasePage: React.FC = () => { isIconOnly color="danger" className="size-8 p-1 " - onPressEnd={pulsOverlappingTables} + onPressEnd={puls} > diff --git a/src/pages/database/db-controller/tables-controller/tables-controller.tsx b/src/pages/database/db-controller/tables-controller/tables-controller.tsx index bb3d8d1..59f0483 100644 --- a/src/pages/database/db-controller/tables-controller/tables-controller.tsx +++ b/src/pages/database/db-controller/tables-controller/tables-controller.tsx @@ -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 = ({ }) => { 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(() => { diff --git a/src/pages/database/table/field.tsx b/src/pages/database/table/field.tsx index fa2196e..617ad53 100644 --- a/src/pages/database/table/field.tsx +++ b/src/pages/database/table/field.tsx @@ -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) => { setEditMode(false); }, [fieldName]) - + return (
{ diff --git a/src/pages/database/table/table.tsx b/src/pages/database/table/table.tsx index 54b928b..44f4779 100644 --- a/src/pages/database/table/table.tsx +++ b/src/pages/database/table/table.tsx @@ -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> = ({ selected, data: { table, overlapping = false, pulsing = false , highlightedEdges = [] } }) => { +const Table: React.FC> = ({ selected, data: { table, overlapping = false, pulsing = false, highlightedEdges = [] } }) => { const [editMode, setEditMode] = useState(false); @@ -50,9 +51,9 @@ const Table: React.FC> = ({ 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> = ({ selected, data: { table, overl />) }) }, [table.fields, selected, highlightedEdges]); - - - - return ( + + + + return ( > = ({ 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; }) diff --git a/src/providers/database-provider/database-context.ts b/src/providers/database-provider/database-context.ts index 050b094..f882403 100644 --- a/src/providers/database-provider/database-context.ts +++ b/src/providers/database-provider/database-context.ts @@ -26,13 +26,13 @@ interface DatabaseOperationsContextType { createTable: (table: TableInsertType) => Promise, editTable: (table: TableInsertType) => Promise, deleteTable: (id: string) => Promise, - updateTablePositions: (tables: TableInsertType[]) => Promise, + updateTablePositions: (tables: TableInsertType[]) => Promise, deleteMultiTables: (ids: string[]) => Promise // field operations createField: (field: FieldInsertType) => Promise, editField: (field: FieldInsertType) => Promise, deleteField: (id: string) => Promise, - orderTableFields: (fields: FieldType[]) => Promise, + orderTableFields: (fields: FieldType[]) => Promise, // relationship operations createRelationship: (relationship: RelationshipInsertType) => Promise, editRelationship: (relationship: RelationshipInsertType) => Promise, diff --git a/src/providers/database-provider/database-provider.tsx b/src/providers/database-provider/database-provider.tsx index 0c59f60..b66e3fd 100644 --- a/src/providers/database-provider/database-provider.tsx +++ b/src/providers/database-provider/database-provider.tsx @@ -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 = ({ children }) => { const [currentDatabaseId, setCurrentDatabaseId] = useState(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 = ({ 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 = ({ children }) => { const isLoading: boolean = loadingDataTypes || loadingDatabases || loadingCurrentDatabase; + // CRUD operations for Tables const createTable = useCallback(async (table: TableInsertType): Promise => { - 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 => { @@ -99,6 +105,7 @@ const DatabaseProvider: React.FC = ({ children }) => { return await db.delete(tables).where(inArray(tables.id, ids)); }, [db]); + // CRUD operations for Fields const createField = useCallback(async (field: FieldInsertType): Promise => { return await db.insert(fields).values(field); }, [db]); @@ -107,6 +114,7 @@ const DatabaseProvider: React.FC = ({ 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 => { 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 = ({ 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 => { - - 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 => { + 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 => { 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 = ({ children }) => { return await db.delete(relationships).where(inArray(relationships.id, ids)); }, [db]); - const updateTablePositions = useCallback(async (tableList: TableInsertType[]): Promise => { - - 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 => { + 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 = ({ children }) => { } } }) - } catch (error) { + } catch (error) { throw error } - }, [db]); - - const databaseOpsValue = useMemo(() => ({ createTable, editTable, @@ -245,7 +228,6 @@ const DatabaseProvider: React.FC = ({ children }) => { editField, deleteField, orderTableFields, - createRelationship, editRelationship, deleteRelationship, @@ -261,7 +243,6 @@ const DatabaseProvider: React.FC = ({ children }) => { editField, deleteField, orderTableFields, - createRelationship, editRelationship, deleteRelationship, @@ -270,7 +251,6 @@ const DatabaseProvider: React.FC = ({ children }) => { ]); return ( - = ({ children }) => { getField, }}> - { !isLoading && database && @@ -294,6 +273,3 @@ export const useDatabase = () => useContext(DatabaseDataContext); export const useDatabaseOperations = () => useContext(DatabaseOperationsContext); export default DatabaseProvider; - - - diff --git a/src/utils/database.ts b/src/utils/database.ts index 21ef625..f4615b1 100644 --- a/src/utils/database.ts +++ b/src/utils/database.ts @@ -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 }; +/** + * 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> = {}; const fieldChanges: Record>> = {}; const fieldCreates: Record = {}; const fieldDeletes: Record = {}; - const relationshipChanges: Record> = {}; 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"] }) - ]) ) }; } - - - \ No newline at end of file diff --git a/src/utils/tables.ts b/src/utils/tables.ts index 0e75643..aac764a 100644 --- a/src/utils/tables.ts +++ b/src/utils/tables.ts @@ -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 } \ No newline at end of file