mirror of
https://github.com/stackrender/stackrender.git
synced 2026-09-10 03:05:42 +00:00
Add undo / redo functionalities
This commit is contained in:
+4
-1
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> { };
|
||||
@@ -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> { };
|
||||
|
||||
|
||||
@@ -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,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 }}>
|
||||
|
||||
+8
-6
@@ -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")}
|
||||
|
||||
|
||||
+7
-2
@@ -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 (
|
||||
|
||||
+5
-5
@@ -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} />
|
||||
|
||||
+14
-8
@@ -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")}
|
||||
/>
|
||||
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+8
-7
@@ -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) => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user