From e0c31e0cb662a52b5536ed2d5c58dce7d7955575 Mon Sep 17 00:00:00 2001 From: KarimTamani Date: Tue, 20 May 2025 15:59:26 +0100 Subject: [PATCH] Add undo / redo functionalities --- package.json | 5 +- src/App.tsx | 3 + src/components/color-picker/color-picker.tsx | 8 +- src/components/menu/menu-dropdown.tsx | 12 +- src/components/menu/menu.tsx | 18 +- src/components/sidebar/sidebar.tsx | 29 +-- src/i18/languages/en.ts | 8 + src/lib/schemas/app-schema.ts | 9 +- src/lib/schemas/database-schema.ts | 26 +++ src/lib/schemas/relationship-schema.ts | 19 +- src/lib/schemas/table-schema.ts | 17 +- .../database/database-control-buttons.tsx | 206 ++++++++++++++++++ src/pages/database/database-page.tsx | 51 ++--- .../create-relationship-form.tsx | 14 +- .../relationship-accordion-body.tsx | 9 +- .../relationship-controller.tsx | 10 +- .../table-accordion-item/field/field-item.tsx | 22 +- .../table-accordion-body.tsx | 6 +- .../table-accordion-header.tsx | 15 +- .../tables-controller/tables-controller.tsx | 26 +-- src/pages/database/table/relationship.tsx | 1 + src/pages/database/table/table.tsx | 34 +-- .../database-history-context.tsx | 22 ++ .../database-history-provider.tsx | 79 +++++++ .../database-provider/database-context.ts | 17 +- .../database-provider/database-provider.tsx | 181 +++++++++++---- .../diagram-provider/diagram-provider.tsx | 14 +- src/providers/sync-provider/sync-provider.tsx | 1 + src/utils/database.ts | 174 +++++++++++++++ src/utils/utils.ts | 49 ++++- 30 files changed, 883 insertions(+), 202 deletions(-) create mode 100644 src/pages/database/database-control-buttons.tsx create mode 100644 src/providers/database-history/database-history-context.tsx create mode 100644 src/providers/database-history/database-history-provider.tsx create mode 100644 src/utils/database.ts diff --git a/package.json b/package.json index bbd7217..5981a1f 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,12 @@ "@xyflow/react": "^12.6.3", "drizzle-orm": "^0.43.1", "elkjs": "^0.10.0", + "fast-json-patch": "^3.1.1", "framer-motion": "11.15.0", "i18next-browser-languagedetector": "^8.0.5", "lucide-react": "^0.501.0", "next-themes": "^0.4.6", + "object-hash": "^3.0.0", "react": "18.3.1", "react-dom": "18.3.1", "react-i18next": "^15.5.1", @@ -34,11 +36,12 @@ "tailwind-merge": "^3.2.0", "tailwind-variants": "0.3.0", "tailwindcss": "3.4.16", + "use-undo": "^1.1.1", "uuid": "^11.1.0" }, "devDependencies": { - "@types/dagre": "^0.7.52", "@types/node": "20.5.7", + "@types/object-hash": "^3.0.6", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", "@typescript-eslint/eslint-plugin": "8.11.0", diff --git a/src/App.tsx b/src/App.tsx index cf2a5de..f5259a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { NextUIProvider } from "@nextui-org/react"; import { ThemeProvider as NextThemesProvider } from "next-themes"; + function App() { const appRoutes = useAppRoutes(); @@ -24,9 +25,11 @@ function App() { + {appRoutes} + diff --git a/src/components/color-picker/color-picker.tsx b/src/components/color-picker/color-picker.tsx index 22ca811..3c5da6f 100644 --- a/src/components/color-picker/color-picker.tsx +++ b/src/components/color-picker/color-picker.tsx @@ -1,7 +1,7 @@ import { colorOptions } from "@/lib/colors"; import { Button, Popover, PopoverContent, PopoverTrigger } from "@heroui/react"; import { Slash } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip/tooltip"; import { useTranslation } from "react-i18next"; @@ -15,7 +15,11 @@ interface ColorPickerProps { const ColorPicker: React.FC = ({ defaultColor, onChange }) => { const [color, setColor] = useState(defaultColor as string); const [isOpen, setIsOpen] = useState(false); - const { t } = useTranslation(); + const { t } = useTranslation(); + + useEffect(() => { + setColor(defaultColor) ; + } , [defaultColor]) const onSelect = useCallback((color: string | undefined) => { setIsOpen(false); diff --git a/src/components/menu/menu-dropdown.tsx b/src/components/menu/menu-dropdown.tsx index aeb733b..72615c6 100644 --- a/src/components/menu/menu-dropdown.tsx +++ b/src/components/menu/menu-dropdown.tsx @@ -22,24 +22,16 @@ export interface MenuDropdownProps { isOpen?: boolean , clickHandler? : () => void } - - - const DropdownMenu: React.FC = ({ title, children, isOpen , clickHandler }) => { - - - - const disabledChilds: string[] = useMemo(() => { return children ? children?.filter((child: MenuDropdownProps) => child.isDisabled && child.title).map((child: MenuDropdownProps) => child.title as string) : [] }, [children]); - - return + return { title && - + + + + {t("control_buttons.undo")} + + Cntl + Z + + + + + + + + + + + + {t("control_buttons.adjust_positions")} + + + + + + + + + + + + + {t("control_buttons.zoom_out")} + + + + + + + + + + + + + {t("control_buttons.zoom_in")} + + + + + + + + + + + {t("control_buttons.show_all")} + + + Cntl + A + + + + + + + + + + + {t("control_buttons.redo")} + + + Ctnl + Shift + Z + + + + + ) +} + + +export default DatabaseControlButtons; \ No newline at end of file diff --git a/src/pages/database/database-page.tsx b/src/pages/database/database-page.tsx index f89bbe2..3f5baf8 100644 --- a/src/pages/database/database-page.tsx +++ b/src/pages/database/database-page.tsx @@ -20,18 +20,19 @@ import { Button } from "@heroui/react"; import { LayoutGrid } from "lucide-react"; import { adjustTablesPositions } from "@/utils/tables"; import { useTheme } from "next-themes"; +import DatabaseControlButtons from "./database-control-buttons"; const DatabasePage: React.FC = () => { - const { tables, relationships, updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabase(); + const { database, updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabase(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const { setIsConnectionInProgress } = useDiagram(); const [selectedNodeIds, setSelectedNodeIds] = useState([]); - + const { tables, relationships } = database ; const { fitView } = useReactFlow(); const nodeTypes = useMemo(() => ({ table: Table }), []); @@ -119,19 +120,18 @@ const DatabasePage: React.FC = () => { }, []); - const adjustPositions = useCallback(async () => { + useTableToNode(tables); + useRelationshipToEdge(relationships); + + const adjustPositions = useCallback(async () => { updateTablePositions(await adjustTablesPositions(nodes, relationships)); setTimeout(() => { fitView({ duration: 500 }) }, 500) - - }, [relationships, nodes]) - - useTableToNode(tables); - useRelationshipToEdge(relationships); + }, [relationships, nodes]); return ( @@ -157,32 +157,21 @@ const DatabasePage: React.FC = () => { onConnectStart={onConnectStart} onConnectEnd={onConnectEnd} > -
-
- - - - - - - - - Adjust Positions - - + - - + + diff --git a/src/pages/database/db-controller/relationship-controller/create-relationship-form/create-relationship-form.tsx b/src/pages/database/db-controller/relationship-controller/create-relationship-form/create-relationship-form.tsx index a4623c7..142c081 100644 --- a/src/pages/database/db-controller/relationship-controller/create-relationship-form/create-relationship-form.tsx +++ b/src/pages/database/db-controller/relationship-controller/create-relationship-form/create-relationship-form.tsx @@ -1,4 +1,4 @@ -import Autocomplete from "@/components/auto-complete/auto-complete"; +import Autocomplete from "@/components/auto-complete/auto-complete"; import { FieldType } from "@/lib/schemas/field-schema"; import { RelationshipInsertType } from "@/lib/schemas/relationship-schema"; import { TableType } from "@/lib/schemas/table-schema"; @@ -27,7 +27,9 @@ const CreateRelationshipForm: React.FC = ({ onRelat } as RelationshipInsertType) - const { tables } = useDatabase(); + const { database } = useDatabase(); + const { tables } = database; + const { t } = useTranslation(); const sourceFields: FieldType[] = useMemo(() => { @@ -55,12 +57,12 @@ const CreateRelationshipForm: React.FC = ({ onRelat useEffect(() => setRelationship({ ...relationship, sourceFieldId: "" }), [sourceFields]); useEffect(() => setRelationship({ ...relationship, targetFieldId: "" }), [targetFields]); - useEffect(() => { + useEffect(() => { onRelationshipChanges && onRelationshipChanges(relationship) - }, [relationship , sourceFields , targetFields]); + }, [relationship, sourceFields, targetFields]); useEffect(() => { - onValidationChanges && onValidationChanges ((relationship.sourceFieldId && relationship.targetFieldId && fieldTypesMatches) as boolean) + onValidationChanges && onValidationChanges((relationship.sourceFieldId && relationship.targetFieldId && fieldTypesMatches) as boolean) }, [relationship, fieldTypesMatches]) return ( @@ -119,7 +121,7 @@ const CreateRelationshipForm: React.FC = ({ onRelat { - !fieldTypesMatches && + !fieldTypesMatches &&
{t("db_controller.relationship_error")} diff --git a/src/pages/database/db-controller/relationship-controller/relationship-accordion-item/relationship-accordion-body.tsx b/src/pages/database/db-controller/relationship-controller/relationship-accordion-item/relationship-accordion-body.tsx index 4f842b6..aad07f1 100644 --- a/src/pages/database/db-controller/relationship-controller/relationship-accordion-item/relationship-accordion-body.tsx +++ b/src/pages/database/db-controller/relationship-controller/relationship-accordion-item/relationship-accordion-body.tsx @@ -4,7 +4,7 @@ import { Cardinality, RelationshipInsertType, RelationshipType } from "@/lib/sch import { useDatabase } from "@/providers/database-provider/database-provider"; import { Button, Select, SelectItem, SharedSelection } from "@heroui/react"; import { ChevronsLeftRightEllipsis, FileMinus2, FileOutput, SquareArrowLeft, SquareArrowRight, Trash2 } from "lucide-react"; -import { Key, useState } from "react"; +import { Key, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -33,11 +33,16 @@ const RelationshipAccordionBody: React.FC = ({ r } as RelationshipInsertType); setCardinality(keys as any); - } + } + const removeRelationship = () => { deleteRelationship(relationship.id); } + useEffect(() => { + setCardinality(new Set([relationship.cardinality])) ; + } , [relationship.cardinality]) + if (!relationship.sourceTable || !relationship.targetTable) return; return ( diff --git a/src/pages/database/db-controller/relationship-controller/relationship-controller.tsx b/src/pages/database/db-controller/relationship-controller/relationship-controller.tsx index 25f5a20..7398bee 100644 --- a/src/pages/database/db-controller/relationship-controller/relationship-controller.tsx +++ b/src/pages/database/db-controller/relationship-controller/relationship-controller.tsx @@ -24,7 +24,9 @@ const RelationshipController: React.FC = ({ }) => { const [relationship, setRelationship] = useState(undefined); const [isValid, setIsValid] = useState(false); const { isOpen, onOpen, onOpenChange } = useDisclosure(); - const { createRelationship, relationships } = useDatabase(); + const { createRelationship, database } = useDatabase(); + const { relationships } = database ; + const { t } = useTranslation(); const [selectedRelationship, setSelectedRelationship] = useState(new Set([])); const { focusedRelationshipId } = useDiagram(); @@ -35,8 +37,7 @@ const RelationshipController: React.FC = ({ }) => { createRelationship({ id: newRelationshipId, - ...relationship, - createdAt: new Date().toISOString() + ...relationship, } as RelationshipInsertType); setSelectedRelationship(new Set([newRelationshipId]) as any); @@ -118,13 +119,12 @@ const RelationshipController: React.FC = ({ }) => { + } > diff --git a/src/pages/database/db-controller/tables-controller/table-accordion-item/field/field-item.tsx b/src/pages/database/db-controller/tables-controller/table-accordion-item/field/field-item.tsx index ab0c481..a6f8254 100644 --- a/src/pages/database/db-controller/tables-controller/table-accordion-item/field/field-item.tsx +++ b/src/pages/database/db-controller/tables-controller/table-accordion-item/field/field-item.tsx @@ -1,12 +1,12 @@ import { useSortable } from "@dnd-kit/sortable"; -import { Button, Input, Popover, PopoverContent, PopoverTrigger, Switch, Textarea } from "@heroui/react"; -import { EllipsisVertical, GripVertical, KeyRound, Trash2 } from "lucide-react"; +import { Button, Input, Popover, PopoverContent, PopoverTrigger, Switch, Textarea } from "@heroui/react"; +import { EllipsisVertical, GripVertical, KeyRound, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { CSS } from "@dnd-kit/utilities"; import { FieldInsertType, FieldType } from "@/lib/schemas/field-schema"; -import { Key , useEffect, useState } from "react"; +import { Key, useEffect, useState } from "react"; import { useDatabase } from "@/providers/database-provider/database-provider"; import Autocomplete from "@/components/auto-complete/auto-complete"; import ToggleButton from "@/components/toggle/toggle"; @@ -24,17 +24,21 @@ const FieldItem: React.FC = ({ field }) => { const { deleteField, editField, data_types } = useDatabase(); const [note, setNote] = useState(field.note as string | undefined); + const [selectedType, setSelectedType] = useState(field.typeId as string | undefined); const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform } = useSortable({ id: field.id }); + const { attributes, listeners, setNodeRef, transform } = useSortable({ id: field.id }); const style = { transform: CSS.Transform.toString(transform), }; - useEffect(() => { - setFieldName(field.name) ; - } , [field.name]) + useEffect(() => { + setFieldName(field.name); + }, [field.name]); + useEffect(() => { + setSelectedType(field.typeId as string | undefined); + }, [field.typeId]) const removeField = () => { setPopOverOpen(false); @@ -65,6 +69,7 @@ const FieldItem: React.FC = ({ field }) => { id: field.id, typeId: key } as FieldType); + setSelectedType(key as string | undefined); } const toggleNullable = (nullable: boolean) => { @@ -99,7 +104,8 @@ const FieldItem: React.FC = ({ field }) => { diff --git a/src/pages/database/db-controller/tables-controller/table-accordion-item/table-accordion-body.tsx b/src/pages/database/db-controller/tables-controller/table-accordion-item/table-accordion-body.tsx index ffd3497..be30ec0 100644 --- a/src/pages/database/db-controller/tables-controller/table-accordion-item/table-accordion-body.tsx +++ b/src/pages/database/db-controller/tables-controller/table-accordion-item/table-accordion-body.tsx @@ -2,7 +2,7 @@ import { Accordion, AccordionItem, Button, cn, Textarea } from "@heroui/react"; import { ChevronLeft, FileKey, FileType, Key, MessageSquareQuote, Plus } from "lucide-react"; -import { MouseEventHandler, useCallback, useState } from "react"; +import { MouseEventHandler, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import ColorPicker from "@/components/color-picker/color-picker"; import FieldList from "./field/field-list"; @@ -48,6 +48,10 @@ const TableAccordionBody: React.FC = ({ table }) => { } as TableType) } + + useEffect(() => { + setNote(table.note ? table.note : ""); + }, [table.note]) return (
= ({ table, isOp const [editMode, setEditMode] = useState(false); const { focusOnTable } = useDiagram(); - useEffect(() => { + useEffect(() => { setTableName(table.name); }, [table.name]) - const saveTableName = async () => { - await editTable({ id: table.id, name: tableName }); + const saveTableName = useCallback(async() => { + + await editTable({ id: table.id, name: tableName } as TableInsertType); setEditMode(false); - } + } , [tableName]) const onDeleteTable = async () => { deleteTable(table.id) @@ -74,7 +75,7 @@ const TableAccordionHeader: React.FC = ({ table, isOp className="w-full text-editable truncate px-2 py-1 text-sm font-semibold text-black dark:text-white" onDoubleClick={() => setEditMode(true)} > - {table.name} + {tableName} 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 44db845..1158906 100644 --- a/src/pages/database/db-controller/tables-controller/tables-controller.tsx +++ b/src/pages/database/db-controller/tables-controller/tables-controller.tsx @@ -6,10 +6,9 @@ import { useCallback, useEffect, useState } from "react"; import TableAccordionHeader from "./table-accordion-item/table-accordion-header"; import TableAccordionBody from "./table-accordion-item/table-accordion-body"; import { useDatabase } from "@/providers/database-provider/database-provider"; -import { TableType } from "@/lib/schemas/table-schema"; -import { v4 } from "uuid"; +import { TableInsertType, TableType } from "@/lib/schemas/table-schema"; +import { v4 } from "uuid"; import { useDiagram } from "@/providers/diagram-provider/diagram-provider"; -import { useTheme } from "next-themes"; interface Props { } @@ -18,7 +17,9 @@ interface Props { } const TablesController: React.FC = ({ }) => { - const { tables, createTable } = useDatabase(); + const { database, createTable } = useDatabase(); + + const { tables } = database ; const { t } = useTranslation(); const [selectedTable, setSelectedTable] = useState(new Set([])); const { focusedTableId } = useDiagram(); @@ -29,23 +30,22 @@ const TablesController: React.FC = ({ }) => { await createTable({ id: newTableId, - name: `table_${tables.length + 1}`, - createdAt: new Date().toISOString() - }); + name: `table_${tables.length + 1}`, + } as TableInsertType); setSelectedTable(new Set([newTableId]) as any); }, [tables]); - useEffect(() => { + useEffect(() => { if (focusedTableId) { setSelectedTable(new Set([focusedTableId]) as any); } - } , [focusedTableId]) ; - - const selectedTableId = selectedTable.values().next().value; + }, [focusedTableId]); + + const selectedTableId = selectedTable.values().next().value; + - return (
@@ -105,7 +105,7 @@ const TablesController: React.FC = ({ }) => { > = (props) => { className={cn([ `!stroke-2 ${selected ? '!stroke-primary' : 'stroke-slate-300 dark:stroke-default-500'}`, + ])} onClick={(e) => { diff --git a/src/pages/database/table/table.tsx b/src/pages/database/table/table.tsx index b2df096..7cf5b25 100644 --- a/src/pages/database/table/table.tsx +++ b/src/pages/database/table/table.tsx @@ -13,7 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/to import FieldComponent from "./field"; import { FieldType } from "@/lib/schemas/field-schema"; -import { TableType } from "@/lib/schemas/table-schema"; +import { TableInsertType, TableType } from "@/lib/schemas/table-schema"; import { useDatabase } from "@/providers/database-provider/database-provider"; import { useTranslation } from "react-i18next"; import { RelationshipType } from "@/lib/schemas/relationship-schema"; @@ -31,20 +31,20 @@ const Table: React.FC> = ({ selected, data: { table } }) = const { editTable } = useDatabase(); const { focusOnTable } = useDiagram(); - const { t } = useTranslation(); + const { t } = useTranslation(); - useEffect(() => { + useEffect(() => { setTableName(table.name); }, [table.name]) const saveTableName = useCallback(async () => { - await editTable({ id: table.id, name: tableName }); + await editTable({ id: table.id, name: tableName } as TableInsertType); setEditMode(false); - }, []); + }, [tableName]); const focus = useCallback(() => { focusOnTable(table.id, false); - }, []) + }, [table]) const edges = useStore((store) => store.edges) as Edge[]; @@ -117,7 +117,7 @@ const Table: React.FC> = ({ selected, data: { table } }) = className=" w-full text-editable truncate px-2 py-0.5 text-sm font-bold dark:text-white dark:group-hover:bg-default-900" onDoubleClick={() => setEditMode(true)} > - {table.name} + {tableName} @@ -150,22 +150,4 @@ const Table: React.FC> = ({ selected, data: { table } }) = }; export default React.memo(Table) -//export default React.memo(Table ) ; - - -/* - focused = { false} - tableNodeId={id} - field={field} - highlighted={selectedRelEdges.some( - (edge) => - edge.data?.relationship - .sourceFieldId === field.id || - edge.data?.relationship - .targetFieldId === field.id - )} - visible={visibleFields.includes(field)} - isConnectable={!table.isView} - - -*/ \ No newline at end of file + \ No newline at end of file diff --git a/src/providers/database-history/database-history-context.tsx b/src/providers/database-history/database-history-context.tsx new file mode 100644 index 0000000..9f1c211 --- /dev/null +++ b/src/providers/database-history/database-history-context.tsx @@ -0,0 +1,22 @@ +import { DatabaseType } from "@/lib/schemas/database-schema"; +import { RelationshipType } from "@/lib/schemas/relationship-schema"; +import { TableType } from "@/lib/schemas/table-schema"; +import { createContext, Dispatch, SetStateAction } from "react"; + + + +export interface DatabaseHistoryContextType { + + undo: () => void; + redo: () => void; + + canUndo : boolean ; + canRedo : boolean ; + + present : DatabaseType ; + +} + + + +export default createContext({} as DatabaseHistoryContextType); \ No newline at end of file diff --git a/src/providers/database-history/database-history-provider.tsx b/src/providers/database-history/database-history-provider.tsx new file mode 100644 index 0000000..0e8f134 --- /dev/null +++ b/src/providers/database-history/database-history-provider.tsx @@ -0,0 +1,79 @@ +import { useCallback, useContext, useEffect, useRef } from "react"; +import DatabaseHistoryContext from "./database-history-context"; +import useUndo from 'use-undo'; +import { useDatabase } from "../database-provider/database-provider"; +import hash from 'object-hash'; +import { DBDiffOperation, mapDiffToDBDiffOperation, normalizeDatabase } from "@/utils/database"; +import { compare } from 'fast-json-patch'; +import { DatabaseType } from "@/lib/schemas/database-schema"; + +interface Props { children: React.ReactNode }; + +const DatabaseHistoryProvider: React.FC = ({ children }) => { + + const udpateDbFlag = useRef(false); + const { database, executeDbDiffOps } = useDatabase(); + const [datatbaseState, { set, undo: undoChanges, redo: redoChanges, canUndo, canRedo }] = useUndo(database); + + useEffect(() => { + udpateDbFlag.current = false; + const presentHash: string = hash(datatbaseState.present, { algorithm: 'sha1' }); + const databaseHash: string = hash(database, { algorithm: 'sha1' }); + if (presentHash != databaseHash) + set(database); + + + }, [database]); + + + const undo = useCallback(() => { + udpateDbFlag.current = true; + undoChanges(); + }, [undoChanges, udpateDbFlag]); + + const redo = useCallback(() => { + udpateDbFlag.current = true; + redoChanges(); + }, [redoChanges, udpateDbFlag]); + + useEffect(() => { + if (!udpateDbFlag.current) { + udpateDbFlag.current = true; + return; + } + + const normalizedDatabase = normalizeDatabase(database); + const normalizedPresent = normalizeDatabase(datatbaseState.present); + const differences = compare(normalizedDatabase, normalizedPresent); + + if (differences && differences.length > 0) { + + const operations: DBDiffOperation[] = mapDiffToDBDiffOperation(differences); + executeDbDiffOps(operations) + } + + }, [datatbaseState.present]); + + + + return ( + + {children} + + ) + +} + + +export const useDatabaseHistory = () => useContext(DatabaseHistoryContext); + + +export default DatabaseHistoryProvider; \ No newline at end of file diff --git a/src/providers/database-provider/database-context.ts b/src/providers/database-provider/database-context.ts index ed904dd..607cb07 100644 --- a/src/providers/database-provider/database-context.ts +++ b/src/providers/database-provider/database-context.ts @@ -1,8 +1,10 @@ import { DataType } from "@/lib/schemas/data-type-schema"; +import { DatabaseType } from "@/lib/schemas/database-schema"; import { FieldInsertType, FieldType } from "@/lib/schemas/field-schema"; import { RelationshipInsertType, RelationshipType } from "@/lib/schemas/relationship-schema"; import { TableInsertType, TableType } from "@/lib/schemas/table-schema"; +import { DBDiffOperation } from "@/utils/database"; import { QueryResult } from "@powersync/web"; import { createContext } from "react"; @@ -10,25 +12,28 @@ import { createContext } from "react"; export interface DatabaseContextType { - tables: TableType[], + data_types: DataType[], - relationships: RelationshipType[], + database : DatabaseType , + isLoading : boolean , // table operations - createTable: (table: TableInsertType) => Promise, + createTable: (table: TableInsertType) => Promise, editTable: (table: TableInsertType) => Promise, - deleteTable: (id: string) => Promise, + deleteTable: (id: string) => Promise, updateTablePositions: (tables: TableInsertType[]) => Promise , deleteMultiTables: (ids: string[]) => Promise // field operations createField: (field: FieldInsertType) => Promise, editField: (field: FieldInsertType) => Promise, - deleteField: (id: string) => Promise, + deleteField: (id: string) => Promise, orderTableFields: (fields: FieldType[]) => Promise, // relationship operations createRelationship: (relationship: RelationshipInsertType) => Promise, editRelationship: (relationship: RelationshipInsertType) => Promise, deleteRelationship: (id: string) => Promise, - deleteMultiRelationships: (ids: string[]) => Promise + deleteMultiRelationships: (ids: string[]) => Promise , + + executeDbDiffOps : ( operations : DBDiffOperation[]) => void , } diff --git a/src/providers/database-provider/database-provider.tsx b/src/providers/database-provider/database-provider.tsx index f1f6197..fc50df7 100644 --- a/src/providers/database-provider/database-provider.tsx +++ b/src/providers/database-provider/database-provider.tsx @@ -1,62 +1,97 @@ import DatabaseContext from "./database-context"; -import { useCallback, useContext } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { db, powerSyncDb } from "../sync-provider/sync-provider"; -import { TableInsertType, tables, TableType } from "@/lib/schemas/table-schema"; +import { TableInsertType, tables } from "@/lib/schemas/table-schema"; import { useQuery } from "@powersync/react"; import { toCompilableQuery } from "@powersync/drizzle-driver"; -import { asc, desc, eq, inArray, sql } from "drizzle-orm"; +import { asc, desc, eq, inArray, or } from "drizzle-orm"; import { QueryResult } from "@powersync/web"; import { FieldInsertType, fields, FieldType } from "@/lib/schemas/field-schema"; -import { RelationshipInsertType, relationships, RelationshipType } from "@/lib/schemas/relationship-schema"; +import { RelationshipInsertType, relationships } from "@/lib/schemas/relationship-schema"; +import DatabaseHistoryProvider from "../database-history/database-history-provider"; +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); - const { data: tablesList } = useQuery(toCompilableQuery( - db.query.tables.findMany({ - with: { - fields: { - orderBy: asc(fields.sequence), - with: { - type: true - } - } - }, - orderBy: desc(tables.createdAt) - }) + const { data: databases, isLoading: loadingDatabases } = useQuery(toCompilableQuery( + db.query.databases.findMany() )); - - const { data: relationshipsList } = useQuery(toCompilableQuery( - db.query.relationships.findMany({ - with: { - sourceTable: true, - targetTable: true, - sourceField: true, - targetField: true, - }, - orderBy: desc(tables.createdAt) - }) - )); - const { data: data_types } = useQuery(toCompilableQuery( + const { data: data_types, isLoading: loadingDataTypes } = useQuery(toCompilableQuery( db.query.data_types.findMany() )); + let { data: database, isLoading: loadingCurrentDatabase } = useQuery( + toCompilableQuery( + db.query.databases.findFirst({ + where: (databases, { eq }) => eq(databases.id, currentDatabaseId as string), + with: { + tables: { + orderBy: desc(tables.createdAt), + with: { + fields: { + orderBy: asc(fields.sequence), + with: { + type: true + } + } + } + }, + relationships: { + with: { + sourceTable: true, + targetTable: true, + sourceField: true, + targetField: true, + }, + orderBy: desc(relationships.createdAt) + } + } + }) + ) + ); - const createTable = useCallback(async (table: TableInsertType): Promise => { - return await db.insert(tables).values(table); - }, [db]); + if (database.length == 1) + database = database[0] as any; + + useEffect(() => { + if (databases.length > 0 && !currentDatabaseId) { + setCurrentDatabaseId(databases[0].id); + } + }, [currentDatabaseId, databases]) + + const isLoading: boolean = loadingDataTypes || loadingDatabases || loadingCurrentDatabase; + + 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() + }); + if (table.fields) { + await tx.insert(fields).values(table.fields); + } + }) + } else + throw Error("No Database selected"); + }, [db, currentDatabaseId]); const editTable = useCallback(async (table: TableInsertType): Promise => { return await db.update(tables).set(table).where(eq(tables.id, table.id)); }, [db]); - const deleteTable = useCallback(async (id: string): Promise => { - return await db.delete(tables).where(eq(tables.id, id)); + const deleteTable = useCallback(async (id: string): Promise => { + await db.delete(tables).where(eq(tables.id, id)); }, [db]); const deleteMultiTables = useCallback(async (ids: string[]): Promise => { @@ -71,8 +106,11 @@ const DatabaseProvider: React.FC = ({ children }) => { return await db.update(fields).set(field).where(eq(fields.id, field.id)); }, [db]); - const deleteField = useCallback(async (id: string): Promise => { - return await db.delete(fields).where(eq(fields.id, id)); + 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))); + await tx.delete(fields).where(eq(fields.id, id)); + }) }, [db]); const orderTableFields = useCallback(async (fieldsList: FieldType[]): Promise => { @@ -92,8 +130,15 @@ const DatabaseProvider: React.FC = ({ children }) => { }, [powerSyncDb]); const createRelationship = useCallback(async (relationship: RelationshipInsertType): Promise => { - return await db.insert(relationships).values(relationship); - }, [db]); + if (currentDatabaseId) { + return await db.insert(relationships).values({ + ...relationship, + databaseId: currentDatabaseId, + createdAt: relationship.createdAt ? relationship.createdAt : getTimestamp() + }); + } + throw Error("No database selected"); + }, [db, currentDatabaseId]); const editRelationship = useCallback(async (relationship: RelationshipInsertType): Promise => { return await db.update(relationships).set(relationship).where(eq(relationships.id, relationship.id)); @@ -129,6 +174,49 @@ const DatabaseProvider: React.FC = ({ children }) => { );`; return await powerSyncDb.execute(sql); }, [powerSyncDb]); + + const executeDbDiffOps = useCallback(async (operations: DBDiffOperation[]) => { + + 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") { + + await tx.update(tables).set(operation.changes).where(eq(tables.id, operation.tableId)); + + } else if (operation.type === "DELETE_TABLE") { + await tx.delete(tables).where(eq(tables.id, operation.tableId)); + + } else if (operation.type === "CREATE_FIELD") { + await tx.insert(fields).values(operation.field); + + } else if (operation.type === "DELETE_FIELD") { + await tx.delete(relationships).where(or(eq(relationships.sourceFieldId, operation.fieldId), eq(relationships.targetFieldId, operation.fieldId))); + await tx.delete(fields).where(eq(fields.id, operation.fieldId)); + + } else if (operation.type === "UPDATE_FIELD") { + await tx.update(fields).set(operation.changes).where(eq(fields.id, operation.fieldId)); + + } else if (operation.type === "CREATE_RELATIONSHIP") { + await tx.insert(relationships).values(operation.relationship); + + } else if (operation.type === "DELETE_RELATIONSHIP") { + await tx.delete(relationships).where(eq(relationships.id, operation.relationshipId)); + + } else if (operation.type === "UPDATE_RELATIONSHIP") { + await tx.update(relationships).set(operation.changes).where(eq(relationships.id, operation.relationshipId)); + } + } + }) + + }, [db, currentDatabaseId]) return ( = ({ children }) => { deleteRelationship, deleteMultiRelationships, - tables: tablesList as TableType[], - relationships: relationshipsList as RelationshipType[], - data_types + + data_types, + database: database as unknown as DatabaseType, + isLoading, + executeDbDiffOps }}> - {children} + { + !isLoading && database && + + {children} + + } ) } diff --git a/src/providers/diagram-provider/diagram-provider.tsx b/src/providers/diagram-provider/diagram-provider.tsx index 4c5c67f..11d7a1f 100644 --- a/src/providers/diagram-provider/diagram-provider.tsx +++ b/src/providers/diagram-provider/diagram-provider.tsx @@ -1,5 +1,5 @@ import { useCallback, useContext, useEffect, useMemo, useState } from "react"; - + import { FitViewOptions, useReactFlow } from "@xyflow/react"; import { useNavigate } from "react-router-dom"; import { RelationshipType } from "@/lib/schemas/relationship-schema"; @@ -15,7 +15,7 @@ const DiagramProvider: React.FC = ({ children }) => { const navigate = useNavigate(); const [focusedTableId, setFocusedTableId] = useState(undefined) const [focusedRelationshipId, setFocusedRelationshipId] = useState(undefined) - const [isConnectionInProgress , setIsConnectionInProgress] = useState( false) ; + const [isConnectionInProgress, setIsConnectionInProgress] = useState(false); const focusOnTable = useCallback((id: string, transition: boolean = false) => { navigate("/database/tables"); @@ -44,7 +44,7 @@ const DiagramProvider: React.FC = ({ children }) => { const focusOnRelationship = useCallback((id: string, transition: boolean = false) => { - navigate("/database/relationships"); + navigate("/database/relationships"); setFocusedRelationshipId(id); setEdges((edges) => @@ -76,11 +76,11 @@ const DiagramProvider: React.FC = ({ children }) => { const contextValue = useMemo(() => ({ focusedTableId, focusedRelationshipId, - isConnectionInProgress , + isConnectionInProgress, focusOnTable, - focusOnRelationship , - setIsConnectionInProgress - }), [focusedTableId, focusedRelationshipId, focusOnTable, focusOnRelationship , isConnectionInProgress , setIsConnectionInProgress ]); + focusOnRelationship, + setIsConnectionInProgress + }), [focusedTableId, focusedRelationshipId, focusOnTable, focusOnRelationship, isConnectionInProgress, setIsConnectionInProgress]); return ( = wrapPowerSyncWithDrizzle(powerSyncDb, { diff --git a/src/utils/database.ts b/src/utils/database.ts new file mode 100644 index 0000000..21ef625 --- /dev/null +++ b/src/utils/database.ts @@ -0,0 +1,174 @@ +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"; + + + +export type DBDiffOperation = + | { type: 'CREATE_TABLE'; table: TableType } + | { type: 'DELETE_TABLE'; tableId: string } + | { type: 'UPDATE_TABLE'; tableId: string; changes: Partial } + | { type: 'CREATE_FIELD'; tableId: string; field: FieldType } + | { type: 'DELETE_FIELD'; tableId: string; fieldId: string } + | { type: 'UPDATE_FIELD'; tableId: string; fieldId: string; changes: Partial } + | { type: 'CREATE_RELATIONSHIP'; relationship: RelationshipType } + | { type: 'DELETE_RELATIONSHIP'; relationshipId: string } + | { type: 'UPDATE_RELATIONSHIP'; relationshipId: string; changes: Partial }; + +export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] { + + const operations: DBDiffOperation[] = []; + + const tableChanges: Record> = {}; + const fieldChanges: Record>> = {}; + const fieldCreates: Record = {}; + const fieldDeletes: Record = {}; + + const relationshipChanges: Record> = {}; + const relationshipCreates: RelationshipType[] = []; + const relationshipDeletes: string[] = []; + + for (const op of patch) { + const parts = op.path.split('/').filter(Boolean); + + if (parts[0] == "tables") { + const tableId = parts[1]; + + + if (parts.length === 2) { + if (op.op === 'add') { + operations.push({ type: 'CREATE_TABLE', table: op.value }); + } else if (op.op === 'remove') { + operations.push({ type: 'DELETE_TABLE', tableId }); + } + } + + + else if (parts[2] === 'fields') { + const fieldId = parts[3]; + + if (op.op === 'add') { + fieldCreates[tableId] ??= []; + fieldCreates[tableId].push(op.value); + } else if (op.op === 'remove') { + fieldDeletes[tableId] ??= []; + fieldDeletes[tableId].push(fieldId); + } else if (op.op === 'replace') { + const attr = parts.slice(4).join('/'); + fieldChanges[tableId] ??= {}; + fieldChanges[tableId][fieldId] ??= {}; + fieldChanges[tableId][fieldId][attr as keyof FieldType] = op.value; + } + } + + + else if (op.op === 'replace') { + const attr = parts[2]; + tableChanges[tableId] ??= {}; + tableChanges[tableId][attr as keyof TableType] = op.value; + } + } + + else if (parts[0] === 'relationships') { + + const relationshipId = parts[1]; + + 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') { + const attr = parts.slice(2).join('/'); + relationshipChanges[relationshipId] ??= {}; + relationshipChanges[relationshipId][attr as keyof RelationshipType] = op.value; + } + } + } + + + for (const [tableId, changes] of Object.entries(tableChanges)) { + operations.push({ + type: 'UPDATE_TABLE', + tableId, + changes + }); + } + + for (const [tableId, fields] of Object.entries(fieldChanges)) { + for (const [fieldId, changes] of Object.entries(fields)) { + operations.push({ + type: 'UPDATE_FIELD', + tableId, + fieldId, + changes + }); + } + } + + for (const [tableId, fields] of Object.entries(fieldCreates)) { + for (const field of fields) { + operations.push({ + type: 'CREATE_FIELD', + tableId, + field + }); + } + } + + for (const [tableId, fieldIds] of Object.entries(fieldDeletes)) { + for (const fieldId of fieldIds) { + operations.push({ + type: 'DELETE_FIELD', + tableId, + fieldId + }); + } + } + + for (const relationship of relationshipCreates) { + operations.push({ type: 'CREATE_RELATIONSHIP', relationship }); + } + + for (const relationshipId of relationshipDeletes) { + operations.push({ type: 'DELETE_RELATIONSHIP', relationshipId }); + } + + for (const [relationshipId, changes] of Object.entries(relationshipChanges)) { + operations.push({ type: 'UPDATE_RELATIONSHIP', relationshipId, changes }); + } + return operations; +} + +export function normalizeDatabase(db: DatabaseType): any { + + return { + ...db, + tables: Object.fromEntries( + db.tables.map(table => [ + table.id, + { + ...table, + fields: Object.fromEntries( + table.fields.map(field => [field.id, field]) + ) + } + ]) + ), + relationships: Object.fromEntries( + 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/utils.ts b/src/utils/utils.ts index bf7945c..98b7bd5 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -8,6 +8,53 @@ const areArraysEqual = (a: string[], b: string[]): boolean => { }; +function getTimestamp() { + const date = new Date(); + + // Get ISO string and split date and time + const [datePart, timePart] = date.toISOString().split('T'); + + // Extract milliseconds + const [time, msZ] = timePart.split('.'); + const milliseconds = msZ.slice(0, -1); // remove 'Z' + + // Add 3 random digits to simulate microseconds (since JS only gives ms) + const microseconds = milliseconds.padEnd(3, '0') + Math.floor(Math.random() * 1000).toString().padStart(3, '0'); + + return `${datePart} ${time}.${microseconds}Z`; +} + + +type NestedObject = { [key: string]: any }; + +function excludeFields( + obj: NestedObject, + exclusions: { [key: string]: string[] } +): NestedObject { + const newObj: NestedObject = {}; + for (const [key, value] of Object.entries(obj)) { + if (exclusions["root"]?.includes(key)) { + // Skip top-level fields listed in 'root' + continue; + } + if (typeof value === "object" && !Array.isArray(value) && value !== null) { + // Recursively exclude fields in nested objects + newObj[key] = exclusions[key] + ? Object.fromEntries( + Object.entries(value).filter(([k]) => !exclusions[key].includes(k)) + ) + : excludeFields(value, exclusions); + } else { + newObj[key] = value; + } + } + return newObj; +} + + + export { - areArraysEqual + areArraysEqual , + getTimestamp , + excludeFields } \ No newline at end of file