diff --git a/package-lock.json b/package-lock.json index c75367b1..695a1880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.0", + "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.1", @@ -1664,6 +1665,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.1.tgz", + "integrity": "sha512-wvMKKIeb3eOrkJ96s722vcidZ+2ZNfcYZWBPRHIB1VWrF+fiF851Io6LX0kmK5wTDQFKdulCCKJk2c3SBaQHvA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-menu": "2.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz", diff --git a/package.json b/package.json index 09c98c49..2f055839 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.0", + "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.1", diff --git a/src/components/context-menu/context-menu.tsx b/src/components/context-menu/context-menu.tsx new file mode 100644 index 00000000..0f7059c7 --- /dev/null +++ b/src/components/context-menu/context-menu.tsx @@ -0,0 +1,202 @@ +import * as React from 'react'; +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from '@radix-ui/react-icons'; + +import { cn } from '@/lib/utils'; + +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = 'ContextMenuShortcut'; + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 49ed4cb5..84485da3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -228,6 +228,15 @@ export const en = { many_to_one: 'Many to One', many_to_many: 'Many to Many', }, + + canvas_context_menu: { + new_table: 'New Table', + }, + + table_node_context_menu: { + edit_table: 'Edit Table', + delete_table: 'Delete Table', + }, }, }; diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index ff9930a0..06ba10dc 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -229,6 +229,15 @@ export const es: LanguageTranslation = { many_to_one: 'Muchos a Uno', many_to_many: 'Muchos a Muchos', }, + + canvas_context_menu: { + new_table: 'Nueva Tabla', + }, + + table_node_context_menu: { + edit_table: 'Editar Tabla', + delete_table: 'Eliminar Tabla', + }, }, }; diff --git a/src/pages/editor-page/canvas/canvas-context-menu.tsx b/src/pages/editor-page/canvas/canvas-context-menu.tsx new file mode 100644 index 00000000..959733f9 --- /dev/null +++ b/src/pages/editor-page/canvas/canvas-context-menu.tsx @@ -0,0 +1,43 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/components/context-menu/context-menu'; +import { useChartDB } from '@/hooks/use-chartdb'; +import { useReactFlow } from '@xyflow/react'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const CanvasContextMenu: React.FC = ({ + children, +}) => { + const { createTable } = useChartDB(); + const { screenToFlowPosition } = useReactFlow(); + const { t } = useTranslation(); + + const createTableHandler = useCallback( + (event: React.MouseEvent) => { + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + createTable({ + x: position.x, + y: position.y, + }); + }, + [createTable, screenToFlowPosition] + ); + return ( + + {children} + + + {t('canvas_context_menu.new_table')} + + + + ); +}; diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index b1122e59..d98a85fd 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -17,10 +17,17 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import equal from 'fast-deep-equal'; -import { MIN_TABLE_SIZE, TableNode, TableNodeType } from './table-node'; +import { + MIN_TABLE_SIZE, + TableNode, + TableNodeType, +} from './table-node/table-node'; import { TableEdge, TableEdgeType } from './table-edge'; import { useChartDB } from '@/hooks/use-chartdb'; -import { LEFT_HANDLE_ID_PREFIX, TARGET_ID_PREFIX } from './table-node-field'; +import { + LEFT_HANDLE_ID_PREFIX, + TARGET_ID_PREFIX, +} from './table-node/table-node-field'; import { Toolbar } from './toolbar/toolbar'; import { useToast } from '@/components/toast/use-toast'; import { Pencil, LayoutGrid } from 'lucide-react'; @@ -43,6 +50,7 @@ import { } from '@/components/tooltip/tooltip'; import { useDialog } from '@/hooks/use-dialog'; import { MarkerDefinitions } from './marker-definitions'; +import { CanvasContextMenu } from './canvas-context-menu'; type AddEdgeParams = Parameters>[0]; @@ -359,111 +367,113 @@ export const Canvas: React.FC = ({ initialTables }) => { }, [t, showAlert, reorderTables]); return ( -
- - - - - - - - - - {t('toolbar.reorder_diagram')} - - - - {isLoadingDOM ? ( - - - {t('loading_diagram')} - - - ) : null} - - {!isDesktop ? ( - - - - ) : null} - - - - +
+ - - - -
+ fitView={false} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + defaultEdgeOptions={{ + animated: false, + type: 'table-edge', + }} + panOnScroll={scrollAction === 'pan'} + > + + + + + + + + + {t('toolbar.reorder_diagram')} + + + + {isLoadingDOM ? ( + + + {t('loading_diagram')} + + + ) : null} + + {!isDesktop ? ( + + + + ) : null} + + + + + +
+ +
+ ); }; diff --git a/src/pages/editor-page/canvas/table-edge.tsx b/src/pages/editor-page/canvas/table-edge.tsx index ab420845..6503b5da 100644 --- a/src/pages/editor-page/canvas/table-edge.tsx +++ b/src/pages/editor-page/canvas/table-edge.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Edge, EdgeProps, @@ -7,7 +7,7 @@ import { useReactFlow, } from '@xyflow/react'; import { DBRelationship } from '@/lib/domain/db-relationship'; -import { RIGHT_HANDLE_ID_PREFIX } from './table-node-field'; +import { RIGHT_HANDLE_ID_PREFIX } from './table-node/table-node-field'; import { useChartDB } from '@/hooks/use-chartdb'; import { useLayout } from '@/hooks/use-layout'; import { cn } from '@/lib/utils'; @@ -39,18 +39,22 @@ export const TableEdge: React.FC> = ({ const relationship = data?.relationship; - const openRelationshipInEditor = () => { + const openRelationshipInEditor = useCallback(() => { selectSidebarSection('relationships'); openRelationshipFromSidebar(id); - }; + }, [id, openRelationshipFromSidebar, selectSidebarSection]); - const edgeNumber = relationships - .filter( - (relationship) => - relationship.targetTableId === target && - relationship.sourceTableId === source - ) - .findIndex((relationship) => relationship.id === id); + const edgeNumber = useMemo( + () => + relationships + .filter( + (relationship) => + relationship.targetTableId === target && + relationship.sourceTableId === source + ) + .findIndex((relationship) => relationship.id === id), + [relationships, id, source, target] + ); const sourceNode = getInternalNode(source); const targetNode = getInternalNode(target); @@ -130,16 +134,24 @@ export const TableEdge: React.FC> = ({ ] ); - const sourceMarker = getCardinalityMarkerId({ - cardinality: relationship?.sourceCardinality ?? 'one', - selected: selected ?? false, - side: sourceSide as 'left' | 'right', - }); - const targetMarker = getCardinalityMarkerId({ - cardinality: relationship?.targetCardinality ?? 'one', - selected: selected ?? false, - side: targetSide as 'left' | 'right', - }); + const sourceMarker = useMemo( + () => + getCardinalityMarkerId({ + cardinality: relationship?.sourceCardinality ?? 'one', + selected: selected ?? false, + side: sourceSide as 'left' | 'right', + }), + [relationship?.sourceCardinality, selected, sourceSide] + ); + const targetMarker = useMemo( + () => + getCardinalityMarkerId({ + cardinality: relationship?.targetCardinality ?? 'one', + selected: selected ?? false, + side: targetSide as 'left' | 'right', + }), + [relationship?.targetCardinality, selected, targetSide] + ); return ( <> ; - -export const MAX_TABLE_SIZE = 450; -export const MID_TABLE_SIZE = 337; -export const MIN_TABLE_SIZE = 224; -export const TABLE_MINIMIZED_FIELDS = 10; - -export const TableNode: React.FC> = ({ - selected, - dragging, - id, - data: { table }, -}) => { - const { updateTable, relationships } = useChartDB(); - const edges = useStore((store) => store.edges) as TableEdgeType[]; - const { openTableFromSidebar, selectSidebarSection } = useLayout(); - const [expanded, setExpanded] = useState(false); - const { t } = useTranslation(); - - const selectedEdges = edges.filter( - (edge) => - (edge.source === id || edge.target === id) && - (edge.selected || edge.data?.highlighted) - ); - - const focused = !!selected && !dragging; - - const openTableInEditor = () => { - selectSidebarSection('tables'); - openTableFromSidebar(table.id); - }; - - const expandTable = useCallback(() => { - updateTable(table.id, { - width: - (table.width ?? MIN_TABLE_SIZE) < MID_TABLE_SIZE - ? MID_TABLE_SIZE - : MAX_TABLE_SIZE, - }); - }, [table.id, table.width, updateTable]); - - const shrinkTable = useCallback(() => { - updateTable(table.id, { - width: MIN_TABLE_SIZE, - }); - }, [table.id, updateTable]); - - const toggleExpand = () => { - setExpanded(!expanded); - }; - - const isMustDisplayedField = useCallback( - (field: DBField) => { - return ( - relationships.some( - (relationship) => - relationship.sourceFieldId === field.id || - relationship.targetFieldId === field.id - ) || field.primaryKey - ); - }, - [relationships] - ); - - const visibleFields = useMemo(() => { - if (expanded) { - return table.fields; - } - - const mustDisplayedFields = table.fields.filter((field: DBField) => - isMustDisplayedField(field) - ); - const nonMustDisplayedFields = table.fields.filter( - (field: DBField) => !isMustDisplayedField(field) - ); - - const visibleMustDisplayedFields = mustDisplayedFields.slice( - 0, - TABLE_MINIMIZED_FIELDS - ); - const remainingSlots = - TABLE_MINIMIZED_FIELDS - visibleMustDisplayedFields.length; - const visibleNonMustDisplayedFields = nonMustDisplayedFields.slice( - 0, - remainingSlots - ); - - return [ - ...visibleMustDisplayedFields, - ...visibleNonMustDisplayedFields, - ].sort((a, b) => table.fields.indexOf(a) - table.fields.indexOf(b)); - }, [expanded, table.fields, isMustDisplayedField]); - - return ( -
{ - if (e.detail === 2) { - openTableInEditor(); - } - }} - > - event.dy === 0} - handleClassName="!hidden" - /> -
-
-
- - -
-
- - -
-
-
- {table.fields.map((field: DBField) => ( - - edge.data?.relationship.sourceFieldId === - field.id || - edge.data?.relationship.targetFieldId === - field.id - )} - visible={visibleFields.includes(field)} - /> - ))} -
- {table.fields.length > TABLE_MINIMIZED_FIELDS && ( -
- {expanded ? ( - <> - - {t('show_less')} - - ) : ( - <> - - {t('show_more')} - - )} -
- )} -
- ); -}; diff --git a/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx b/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx new file mode 100644 index 00000000..d5adbd40 --- /dev/null +++ b/src/pages/editor-page/canvas/table-node/table-node-context-menu.tsx @@ -0,0 +1,44 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/components/context-menu/context-menu'; +import { useChartDB } from '@/hooks/use-chartdb'; +import { useLayout } from '@/hooks/use-layout'; +import { DBTable } from '@/lib/domain/db-table'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface TableNodeContextMenuProps { + table: DBTable; +} + +export const TableNodeContextMenu: React.FC< + React.PropsWithChildren +> = ({ children, table }) => { + const { removeTable } = useChartDB(); + const { openTableFromSidebar } = useLayout(); + const { t } = useTranslation(); + + const editTableHandler = useCallback(() => { + openTableFromSidebar(table.id); + }, [openTableFromSidebar, table.id]); + + const removeTableHandler = useCallback(() => { + removeTable(table.id); + }, [removeTable, table.id]); + return ( + + {children} + + + {t('table_node_context_menu.edit_table')} + + + {t('table_node_context_menu.delete_table')} + + + + ); +}; diff --git a/src/pages/editor-page/canvas/table-node-field.tsx b/src/pages/editor-page/canvas/table-node/table-node-field.tsx similarity index 100% rename from src/pages/editor-page/canvas/table-node-field.tsx rename to src/pages/editor-page/canvas/table-node/table-node-field.tsx diff --git a/src/pages/editor-page/canvas/table-node/table-node.tsx b/src/pages/editor-page/canvas/table-node/table-node.tsx new file mode 100644 index 00000000..82facdf7 --- /dev/null +++ b/src/pages/editor-page/canvas/table-node/table-node.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useState, useMemo } from 'react'; +import { NodeProps, Node, NodeResizer, useStore } from '@xyflow/react'; +import { Button } from '@/components/button/button'; +import { + ChevronsLeftRight, + ChevronsRightLeft, + Pencil, + Table2, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { Label } from '@/components/label/label'; +import { DBTable } from '@/lib/domain/db-table'; +import { TableNodeField } from './table-node-field'; +import { useLayout } from '@/hooks/use-layout'; +import { useChartDB } from '@/hooks/use-chartdb'; +import { TableEdgeType } from '../table-edge'; +import { DBField } from '@/lib/domain/db-field'; +import { useTranslation } from 'react-i18next'; +import { TableNodeContextMenu } from './table-node-context-menu'; + +export type TableNodeType = Node< + { + table: DBTable; + }, + 'table' +>; + +export const MAX_TABLE_SIZE = 450; +export const MID_TABLE_SIZE = 337; +export const MIN_TABLE_SIZE = 224; +export const TABLE_MINIMIZED_FIELDS = 10; + +export const TableNode: React.FC> = ({ + selected, + dragging, + id, + data: { table }, +}) => { + const { updateTable, relationships } = useChartDB(); + const edges = useStore((store) => store.edges) as TableEdgeType[]; + const { openTableFromSidebar, selectSidebarSection } = useLayout(); + const [expanded, setExpanded] = useState(false); + const { t } = useTranslation(); + + const selectedEdges = edges.filter( + (edge) => + (edge.source === id || edge.target === id) && + (edge.selected || edge.data?.highlighted) + ); + + const focused = !!selected && !dragging; + + const openTableInEditor = () => { + selectSidebarSection('tables'); + openTableFromSidebar(table.id); + }; + + const expandTable = useCallback(() => { + updateTable(table.id, { + width: + (table.width ?? MIN_TABLE_SIZE) < MID_TABLE_SIZE + ? MID_TABLE_SIZE + : MAX_TABLE_SIZE, + }); + }, [table.id, table.width, updateTable]); + + const shrinkTable = useCallback(() => { + updateTable(table.id, { + width: MIN_TABLE_SIZE, + }); + }, [table.id, updateTable]); + + const toggleExpand = () => { + setExpanded(!expanded); + }; + + const isMustDisplayedField = useCallback( + (field: DBField) => { + return ( + relationships.some( + (relationship) => + relationship.sourceFieldId === field.id || + relationship.targetFieldId === field.id + ) || field.primaryKey + ); + }, + [relationships] + ); + + const visibleFields = useMemo(() => { + if (expanded) { + return table.fields; + } + + const mustDisplayedFields = table.fields.filter((field: DBField) => + isMustDisplayedField(field) + ); + const nonMustDisplayedFields = table.fields.filter( + (field: DBField) => !isMustDisplayedField(field) + ); + + const visibleMustDisplayedFields = mustDisplayedFields.slice( + 0, + TABLE_MINIMIZED_FIELDS + ); + const remainingSlots = + TABLE_MINIMIZED_FIELDS - visibleMustDisplayedFields.length; + const visibleNonMustDisplayedFields = nonMustDisplayedFields.slice( + 0, + remainingSlots + ); + + return [ + ...visibleMustDisplayedFields, + ...visibleNonMustDisplayedFields, + ].sort((a, b) => table.fields.indexOf(a) - table.fields.indexOf(b)); + }, [expanded, table.fields, isMustDisplayedField]); + + return ( + +
{ + if (e.detail === 2) { + openTableInEditor(); + } + }} + > + event.dy === 0} + handleClassName="!hidden" + /> +
+
+
+ + +
+
+ + +
+
+
+ {table.fields.map((field: DBField) => ( + + edge.data?.relationship.sourceFieldId === + field.id || + edge.data?.relationship.targetFieldId === + field.id + )} + visible={visibleFields.includes(field)} + /> + ))} +
+ {table.fields.length > TABLE_MINIMIZED_FIELDS && ( +
+ {expanded ? ( + <> + + {t('show_less')} + + ) : ( + <> + + {t('show_more')} + + )} +
+ )} +
+
+ ); +};