Add undo / redo functionalities

This commit is contained in:
KarimTamani
2025-05-20 15:59:26 +01:00
parent d9c260bfce
commit e0c31e0cb6
30 changed files with 883 additions and 202 deletions
+4 -1
View File
@@ -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",
+3
View File
@@ -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() {
<ReactFlowProvider>
<DatabaseProvider>
<DiagramProvider>
<TooltipProvider delayDuration={0}>
{appRoutes}
</TooltipProvider>
</DiagramProvider>
</DatabaseProvider>
</ReactFlowProvider>
+6 -2
View File
@@ -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<ColorPickerProps> = ({ defaultColor, onChange }) => {
const [color, setColor] = useState<string | undefined>(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);
+2 -10
View File
@@ -22,24 +22,16 @@ export interface MenuDropdownProps {
isOpen?: boolean ,
clickHandler? : () => void
}
const DropdownMenu: React.FC<MenuDropdownProps> = ({ 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 <Dropdown radius="sm" >
return <Dropdown radius="sm" showArrow >
{
title &&
<DropdownTrigger onPressEnd={clickHandler}>
<Button size="sm" variant="light" >
<Button size="sm" variant="light" className="min-w-[42px]" >
<span className=" text-left flex justify-between text-small font-semibold">
{title}
</span>
+11 -7
View File
@@ -105,14 +105,14 @@ const Menu: React.FC<MenuProps> = ({ }) => {
children: [{
title: "Light",
clickHandler: () => {
setTheme("light") ;
setTheme("light");
}
}, {
title: "Dark",
clickHandler: () => {
setTheme("dark") ;
setTheme("dark");
}
}]
}]
@@ -127,9 +127,13 @@ const Menu: React.FC<MenuProps> = ({ }) => {
], []);
return menu.map(menuItem => (
<DropdownMenu {...menuItem} />
))
return <div className="gap-1 flex">
{
menu.map((menuItem , index ) => (
<DropdownMenu {...menuItem} key={index} />
))
}
</div>
}
+16 -13
View File
@@ -8,33 +8,33 @@ interface SidebarProps {
children?: React.ReactNode
}
const sidebarItemClass : string = "text-gray-700 dark:text-default-500 size-4 data-[active=true]:text-primary" ;
const sidebarItemClass: string = "text-gray-700 dark:text-default-500 size-4 data-[active=true]:text-primary";
const Sidebar: React.FC<SidebarProps> = ({ }) => {
const { t } = useTranslation() ;
const { t } = useTranslation();
const location = useLocation();
const location = useLocation() ;
const sidebarItems: SidebarItemProps[] = useMemo(() => [
{
title: t("sidebar.tables"),
icon: <TableProperties className={sidebarItemClass}></TableProperties>,
href: "/database/tables",
isActive : location.pathname.endsWith("/database/tables")
isActive: location.pathname.endsWith("/database/tables")
},
{
title: t("sidebar.relationships"),
icon: <Workflow className={sidebarItemClass}></Workflow>,
icon: <Workflow className={sidebarItemClass}></Workflow>,
href: "/database/relationships",
isActive : location.pathname.endsWith("/database/relationships")
isActive: location.pathname.endsWith("/database/relationships")
},
{
title: "AI",
icon: <Sparkles className={sidebarItemClass}></Sparkles>,
icon: <Sparkles className={sidebarItemClass}></Sparkles>,
href: "/database/bot",
isActive : location.pathname.endsWith("/database/bot")
isActive: location.pathname.endsWith("/database/bot")
},
{
type: "divider"
@@ -83,15 +83,16 @@ const Sidebar: React.FC<SidebarProps> = ({ }) => {
icon: <BookOpen className={sidebarItemClass}></BookOpen>,
href: "/database/ai-bot",
},
], []) ;
], []);
return (
<aside className="h-full z-[20] flex flex-col items-between py-2 justify-between sticky top-0 duration-500 w-12 bg-sidebar dark:bg-background border-r pt-[56px] dark:border-default-100">
<div className="flex flex-col items-center gap-2">
{
sidebarItems.map((item: SidebarItemProps) => (
sidebarItems.map((item: SidebarItemProps , index : number) => (
<SidebarItem
key={`top-${index}`}
{...item}
/>
))
@@ -99,8 +100,10 @@ const Sidebar: React.FC<SidebarProps> = ({ }) => {
</div>
<div className="flex flex-col items-center gap-2 ">
{
bottomSidebarItems.map((item: SidebarItemProps) => (
bottomSidebarItems.map((item: SidebarItemProps, index : number) => (
<SidebarItem
key={`bottom-${index}`}
{...item}
/>
))
+8
View File
@@ -66,6 +66,14 @@ export const en = {
},
table: {
double_click: "Double click to edit"
} ,
control_buttons : {
redo : "Redo" ,
undo : "Undo" ,
zoom_in : "Zoom In" ,
zoom_out : "Zoom Out" ,
adjust_positions : "Adjust Positions" ,
show_all : "Show all"
}
}
+6 -3
View File
@@ -1,21 +1,24 @@
import { DrizzleAppSchema, wrapPowerSyncWithDrizzle } from '@powersync/drizzle-driver';
import { DrizzleAppSchema } from '@powersync/drizzle-driver';
import { data_types, dataTypeRelations } from './data-type-schema';
import { tables, tablesRelations } from './table-schema';
import { fields, fieldsRelations } from './field-schema';
import { relationshipRelations, relationships } from './relationship-schema';
import { databaseRelations, databases } from './database-schema';
export const drizzleSchema = {
data_types ,
tables ,
fields ,
relationships ,
relationships ,
databases ,
// relationships
tablesRelations ,
fieldsRelations ,
relationshipRelations ,
dataTypeRelations
dataTypeRelations ,
databaseRelations
};
// Infer the PowerSync schema from your Drizzle schema
+26
View File
@@ -0,0 +1,26 @@
import { InferInsertModel, InferSelectModel, relations } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { tables, TableType } from './table-schema';
import { relationships, RelationshipType } from './relationship-schema';
export const databases = sqliteTable('databases', {
id: text('id')
.primaryKey()
.notNull()
.unique(),
name: text('name').notNull(),
createdAt: text('createdAt'),
});
export const databaseRelations = relations(databases, ({ many }) => ({
tables: many(tables),
relationships: many(relationships),
}));
export interface DatabaseType extends InferSelectModel<typeof databases> {
tables : TableType[] ,
relationships : RelationshipType[]
};
export interface DatabaseInsertType extends InferInsertModel<typeof databases> { };
+14 -5
View File
@@ -5,6 +5,7 @@ import {
import { InferInsertModel, InferSelectModel, relations } from 'drizzle-orm';
import { tables, TableType } from './table-schema';
import { fields, FieldType } from './field-schema';
import { databases } from './database-schema';
export const relationships = sqliteTable('relationships', {
id: text('id').primaryKey().notNull(),
@@ -38,6 +39,10 @@ export const relationships = sqliteTable('relationships', {
.notNull()
.default('one_to_many'),
databaseId: text("databaseId").notNull().references(() => databases.id, { onDelete: "cascade" }),
sourceAliasName: text('sourceAliasName'),
targetAliasName: text('targetAliasName'),
createdAt: text('createdAt'),
@@ -61,15 +66,19 @@ export const relationshipRelations = relations(relationships, ({ one }) => ({
fields: [relationships.targetFieldId],
references: [fields.id],
}),
database: one(databases, {
fields: [relationships.databaseId],
references: [databases.id],
})
}));
export interface RelationshipType extends InferSelectModel<typeof relationships> {
sourceTable : TableType ,
targetTable : TableType ,
sourceField : FieldType ,
targetField : FieldType ,
};
sourceTable: TableType,
targetTable: TableType,
sourceField: FieldType,
targetField: FieldType,
};
export interface RelationshipInsertType extends InferInsertModel<typeof relationships> { };
+12 -5
View File
@@ -2,6 +2,7 @@ import { InferInsertModel, InferSelectModel, relations } from 'drizzle-orm';
import { sqliteTable, text, real, integer } from 'drizzle-orm/sqlite-core';
import { fields, FieldType } from './field-schema';
import { relationships } from './relationship-schema';
import { databases } from './database-schema';
export const tables = sqliteTable('tables', {
id: text('id')
@@ -9,7 +10,7 @@ export const tables = sqliteTable('tables', {
.notNull()
.unique(),
databaseId: text('databaseId'),
databaseId: text("databaseId").notNull().references(() => databases.id, { onDelete: "cascade" }),
name: text('name').notNull(),
posX: real('posX').notNull().default(0),
@@ -20,23 +21,29 @@ export const tables = sqliteTable('tables', {
note: text('note'),
sequence: integer('sequence').default(0),
createdAt: text('createdAt'),
});
export const tablesRelations = relations(tables, ({ many }) => ({
export const tablesRelations = relations(tables, ({ many, one }) => ({
fields: many(fields),
sourceRelations: many(relationships),
targetRelations: many(relationships),
database: one(databases, {
fields: [tables.databaseId],
references: [databases.id],
})
}));
export interface TableType extends InferSelectModel<typeof tables> {
fields: FieldType[]
fields: FieldType[];
};
export interface TableInsertType extends InferInsertModel<typeof tables> {
fields?: FieldType[]
fields?: FieldType[];
};
@@ -0,0 +1,206 @@
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/tooltip/tooltip";
import { useDatabaseHistory } from "@/providers/database-history/database-history-provider";
import { Button, cn, Divider, Navbar, NavbarContent } from "@heroui/react";
import { useOnViewportChange, useReactFlow } from "@xyflow/react";
import { LayoutGrid, Redo, Scan, Undo, ZoomIn, ZoomOut } from "lucide-react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
interface DbControlButtons {
adjustPositions: () => void,
}
const ZOOM_DURATION = 100
const DatabaseControlButtons: React.FC<DbControlButtons> = ({ adjustPositions }) => {
const { zoomIn, zoomOut, fitView } = useReactFlow();
const [zoom, setZoom] = useState<string>();
const { t } = useTranslation();
const { undo, redo, canRedo, canUndo } = useDatabaseHistory();
useOnViewportChange({
onChange: ({ zoom }) => {
setZoom(`${Math.round(zoom * 100)}%`);
},
});
const onZoomIn = useCallback(() => {
zoomIn({ duration: ZOOM_DURATION });
}, [])
const onZoomOut = useCallback(() => {
zoomOut({ duration: ZOOM_DURATION });
}, [])
const onFitView = useCallback(() => {
fitView({
duration: 500,
padding: 0.1,
maxZoom: 0.9,
})
}, [])
const resetZoom = useCallback(() => {
fitView({
duration: 500,
minZoom: 1,
maxZoom: 1,
})
}, []);
return (
<Navbar className="flex rounded-md border-1 border-default-200 bg-transparent dark:border-default-800" isBlurred
classNames={{
wrapper: "h-14 p-2 gap-1",
}}
>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="md"
isIconOnly
variant="light"
onPressEnd={undo}
radius="sm"
disabled={!canUndo}
>
<Undo className={cn(
"size-4 dark:text-white",
!canUndo ? "text-default-400 dark:text-default-600" : ""
)} />
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="dark:bg-default-900">
{t("control_buttons.undo")}
<span className="ml-2 text-slate-400">
Cntl + Z
</span>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="md"
isIconOnly
variant="light"
radius="sm"
onPressEnd={adjustPositions}>
<LayoutGrid className="size-4 dark:text-white" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="dark:bg-default-900">
{t("control_buttons.adjust_positions")}
</TooltipContent>
</Tooltip>
<Divider orientation="vertical" className="bg-default-200 dark:bg-default-800" />
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="md"
isIconOnly
variant="light"
radius="sm"
onPressEnd={onZoomOut}>
<ZoomOut className="size-4 dark:text-white" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="dark:bg-default-900">
{t("control_buttons.zoom_out")}
</TooltipContent>
</Tooltip>
<Button
size="md"
isIconOnly
variant="light"
onPressEnd={resetZoom}
className="w-[60px] p-2 hover:bg-primary-foreground dark:text-white"
>
{zoom}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="md"
isIconOnly
variant="light"
radius="sm"
onPressEnd={onZoomIn}>
<ZoomIn className="size-4 dark:text-white" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="dark:bg-default-900">
{t("control_buttons.zoom_in")}
</TooltipContent>
</Tooltip>
<Divider orientation="vertical" className="bg-default-200 dark:bg-default-800" />
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="md"
isIconOnly
variant="light"
radius="sm"
onPressEnd={onFitView}
>
<Scan className="size-4 dark:text-white" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="dark:bg-default-900">
{t("control_buttons.show_all")}
<span className="ml-2 text-slate-400">
Cntl + A
</span>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="md"
isIconOnly
variant="light"
radius="sm"
onPressEnd={redo}
disabled={!canRedo}
>
<Redo className={cn(
"size-4 dark:text-white",
!canRedo ? "text-default-400 dark:text-default-600" : ""
)} />
</Button>
</span>
</TooltipTrigger>
<TooltipContent className="dark:bg-default-900">
{t("control_buttons.redo")}
<span className="ml-2 text-slate-400">
Ctnl + Shift + Z
</span>
</TooltipContent>
</Tooltip>
</Navbar>
)
}
export default DatabaseControlButtons;
+20 -31
View File
@@ -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<never> = () => {
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<string[]>([]);
const { tables, relationships } = database ;
const { fitView } = useReactFlow();
const nodeTypes = useMemo(() => ({ table: Table }), []);
@@ -119,19 +120,18 @@ const DatabasePage: React.FC<never> = () => {
}, []);
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<never> = () => {
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
>
<div className="absolute top-[160px]">
</div>
<Controls >
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
isIconOnly
variant="flat"
className="size-8 p-1 shadow-none"
onPressEnd={adjustPositions}
<Controls
position="bottom-center"
showFitView={false}
showZoom={false}
showInteractive={false}
className="shadow-none "
>
>
<LayoutGrid className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
Adjust Positions
</TooltipContent>
</Tooltip>
<DatabaseControlButtons
adjustPositions={adjustPositions}
/>
</Controls>
<Background className="bg-default/30 dark:bg-black" />
</Controls >
<Background />
</ReactFlow>
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
@@ -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<CreateRelationshipFormProps> = ({ 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<CreateRelationshipFormProps> = ({ 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<CreateRelationshipFormProps> = ({ onRelat
</div>
</div>
{
!fieldTypesMatches &&
!fieldTypesMatches &&
<div className="w-full text-danger text-sm ">
{t("db_controller.relationship_error")}
@@ -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<RelationshipAccordionBodyProps> = ({ 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 (
@@ -24,7 +24,9 @@ const RelationshipController: React.FC<Props> = ({ }) => {
const [relationship, setRelationship] = useState<RelationshipInsertType | undefined>(undefined);
const [isValid, setIsValid] = useState<boolean>(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<Props> = ({ }) => {
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<Props> = ({ }) => {
<AccordionItem
key={relationship.id}
aria-label={relationship.id}
classNames={{
trigger: "w-full hover:bg-default transition-all duration-200 h-12 dark:bg-background dark:hover:bg-default-50",
base: "rounded-md p-0 overflow-hidden",
}}
subtitle={
<RelationshipAccordionHeader relationship={relationship} />
<RelationshipAccordionHeader relationship={relationship} />
}
>
<RelationshipAccordionBody relationship={relationship} />
@@ -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<Props> = ({ field }) => {
const { deleteField, editField, data_types } = useDatabase();
const [note, setNote] = useState<string | undefined>(field.note as string | undefined);
const [selectedType, setSelectedType] = useState<string | undefined>(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<Props> = ({ 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<Props> = ({ field }) => {
<Autocomplete
items={data_types}
onSelectionChange={updateFieldType}
defaultSelection={field.typeId as any}
selectedItem={selectedType}
placeholder={t("db_controller.type")}
/>
@@ -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<TableAccordionBodyProps> = ({ table }) => {
} as TableType)
}
useEffect(() => {
setNote(table.note ? table.note : "");
}, [table.note])
return (
<div className="w-full dark:bg-background">
<Accordion
@@ -2,10 +2,10 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/tooltip/to
import { Button, cn, Input, Listbox, ListboxItem, Popover, PopoverContent, PopoverTrigger, useDisclosure } from "@heroui/react";
import { Check, ChevronRight, Copy, EllipsisVertical, FileKey, FileType, Focus, Pencil, Trash } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TableType } from "@/lib/schemas/table-schema";
import { TableInsertType, TableType } from "@/lib/schemas/table-schema";
import { useDatabase } from "@/providers/database-provider/database-provider";
import { v4 } from "uuid";
import { getNextSequence } from "@/utils/field";
@@ -26,14 +26,15 @@ const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOp
const [editMode, setEditMode] = useState<boolean>(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<TableAccordionHeaderProps> = ({ 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}
</label>
</TooltipTrigger>
<TooltipContent>
@@ -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<Props> = ({ }) => {
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<Props> = ({ }) => {
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 (
<div className="w-full h-full flex flex-col gap-2">
<div className="flex items-center justify-between gap-4 py-1">
@@ -105,7 +105,7 @@ const TablesController: React.FC<Props> = ({ }) => {
<Accordion
hideIndicator
variant={ "splitted"}
variant={"splitted"}
selectedKeys={selectedTable}
onSelectionChange={setSelectedTable as any}
isCompact
@@ -126,6 +126,7 @@ const Relationship: React.FC<EdgeProps<RelationshipProps>> = (props) => {
className={cn([
`!stroke-2 ${selected ? '!stroke-primary' : 'stroke-slate-300 dark:stroke-default-500'}`,
])}
onClick={(e) => {
+8 -26
View File
@@ -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<NodeProps<TableProps>> = ({ 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<NodeProps<TableProps>> = ({ 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}
</label>
</TooltipTrigger>
<TooltipContent className="dark:bg-default-900">
@@ -150,22 +150,4 @@ const Table: React.FC<NodeProps<TableProps>> = ({ 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}
*/
@@ -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<DatabaseHistoryContextType>({} as DatabaseHistoryContextType);
@@ -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<Props> = ({ children }) => {
const udpateDbFlag = useRef(false);
const { database, executeDbDiffOps } = useDatabase();
const [datatbaseState, { set, undo: undoChanges, redo: redoChanges, canUndo, canRedo }] = useUndo<DatabaseType>(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 (
<DatabaseHistoryContext.Provider
value={{
undo,
redo,
canUndo,
canRedo,
present: datatbaseState.present
}}
>
{children}
</DatabaseHistoryContext.Provider>
)
}
export const useDatabaseHistory = () => useContext(DatabaseHistoryContext);
export default DatabaseHistoryProvider;
@@ -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<QueryResult>,
createTable: (table: TableInsertType) => Promise<void>,
editTable: (table: TableInsertType) => Promise<QueryResult>,
deleteTable: (id: string) => Promise<QueryResult>,
deleteTable: (id: string) => Promise<void>,
updateTablePositions: (tables: TableInsertType[]) => Promise<QueryResult> ,
deleteMultiTables: (ids: string[]) => Promise<QueryResult>
// field operations
createField: (field: FieldInsertType) => Promise<QueryResult>,
editField: (field: FieldInsertType) => Promise<QueryResult>,
deleteField: (id: string) => Promise<QueryResult>,
deleteField: (id: string) => Promise<void>,
orderTableFields: (fields: FieldType[]) => Promise<QueryResult>,
// relationship operations
createRelationship: (relationship: RelationshipInsertType) => Promise<QueryResult>,
editRelationship: (relationship: RelationshipInsertType) => Promise<QueryResult>,
deleteRelationship: (id: string) => Promise<QueryResult>,
deleteMultiRelationships: (ids: string[]) => Promise<QueryResult>
deleteMultiRelationships: (ids: string[]) => Promise<QueryResult> ,
executeDbDiffOps : ( operations : DBDiffOperation[]) => void ,
}
@@ -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<Props> = ({ children }) => {
const [currentDatabaseId, setCurrentDatabaseId] = useState<string | undefined>(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<QueryResult> => {
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<void> => {
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<QueryResult> => {
return await db.update(tables).set(table).where(eq(tables.id, table.id));
}, [db]);
const deleteTable = useCallback(async (id: string): Promise<QueryResult> => {
return await db.delete(tables).where(eq(tables.id, id));
const deleteTable = useCallback(async (id: string): Promise<void> => {
await db.delete(tables).where(eq(tables.id, id));
}, [db]);
const deleteMultiTables = useCallback(async (ids: string[]): Promise<QueryResult> => {
@@ -71,8 +106,11 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
return await db.update(fields).set(field).where(eq(fields.id, field.id));
}, [db]);
const deleteField = useCallback(async (id: string): Promise<QueryResult> => {
return await db.delete(fields).where(eq(fields.id, id));
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)));
await tx.delete(fields).where(eq(fields.id, id));
})
}, [db]);
const orderTableFields = useCallback(async (fieldsList: FieldType[]): Promise<QueryResult> => {
@@ -92,8 +130,15 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
}, [powerSyncDb]);
const createRelationship = useCallback(async (relationship: RelationshipInsertType): Promise<QueryResult> => {
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<QueryResult> => {
return await db.update(relationships).set(relationship).where(eq(relationships.id, relationship.id));
@@ -129,6 +174,49 @@ const DatabaseProvider: React.FC<Props> = ({ 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 (
<DatabaseContext.Provider value={{
@@ -148,11 +236,18 @@ const DatabaseProvider: React.FC<Props> = ({ 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 &&
<DatabaseHistoryProvider>
{children}
</DatabaseHistoryProvider>
}
</DatabaseContext.Provider>
)
}
@@ -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<Props> = ({ children }) => {
const navigate = useNavigate();
const [focusedTableId, setFocusedTableId] = useState<string | undefined>(undefined)
const [focusedRelationshipId, setFocusedRelationshipId] = useState<string | undefined>(undefined)
const [isConnectionInProgress , setIsConnectionInProgress] = useState<boolean>( false) ;
const [isConnectionInProgress, setIsConnectionInProgress] = useState<boolean>(false);
const focusOnTable = useCallback((id: string, transition: boolean = false) => {
navigate("/database/tables");
@@ -44,7 +44,7 @@ const DiagramProvider: React.FC<Props> = ({ 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<Props> = ({ 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 (
<DiagramContext.Provider
value={contextValue}
@@ -13,6 +13,7 @@ export const powerSyncDb = new PowerSyncDatabase({
dbFilename: 'stackrender.sqlite'
},
schema: AppSchema,
});
export const db: PowerSyncSQLiteDatabase<typeof drizzleSchema> = wrapPowerSyncWithDrizzle(powerSyncDb, {
+174
View File
@@ -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<TableType> }
| { type: 'CREATE_FIELD'; tableId: string; field: FieldType }
| { type: 'DELETE_FIELD'; tableId: string; fieldId: string }
| { type: 'UPDATE_FIELD'; tableId: string; fieldId: string; changes: Partial<FieldType> }
| { type: 'CREATE_RELATIONSHIP'; relationship: RelationshipType }
| { type: 'DELETE_RELATIONSHIP'; relationshipId: string }
| { type: 'UPDATE_RELATIONSHIP'; relationshipId: string; changes: Partial<RelationshipType> };
export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
const operations: DBDiffOperation[] = [];
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[] = [];
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" ]
})
])
)
};
}
+48 -1
View File
@@ -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
}