add context menu to table & canvas

This commit is contained in:
Guy Ben-Aharon
2024-09-15 09:20:25 +03:00
committed by Guy Ben-Aharon
parent c89d9af8d8
commit 7bc1eaa980
12 changed files with 707 additions and 345 deletions

29
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<DotFilledIcon className="size-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold text-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -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',
},
},
};

View File

@@ -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',
},
},
};

View File

@@ -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<React.PropsWithChildren> = ({
children,
}) => {
const { createTable } = useChartDB();
const { screenToFlowPosition } = useReactFlow();
const { t } = useTranslation();
const createTableHandler = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
createTable({
x: position.x,
y: position.y,
});
},
[createTable, screenToFlowPosition]
);
return (
<ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={createTableHandler}>
{t('canvas_context_menu.new_table')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View File

@@ -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<typeof addEdge<TableEdgeType>>[0];
@@ -359,111 +367,113 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
}, [t, showAlert, reorderTables]);
return (
<div className="relative flex h-full">
<ReactFlow
colorMode={effectiveTheme}
className="canvas-cursor-default nodes-animated"
nodes={nodes}
edges={edges}
onNodesChange={onNodesChangeHandler}
onEdgesChange={onEdgesChangeHandler}
maxZoom={5}
minZoom={0.1}
onConnect={onConnectHandler}
proOptions={{
hideAttribution: true,
}}
fitView={false}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{
animated: false,
type: 'table-edge',
}}
panOnScroll={scrollAction === 'pan'}
>
<Controls
position="top-left"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
className="size-8 p-1 shadow-none"
onClick={showReorderConfirmation}
>
<LayoutGrid className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t('toolbar.reorder_diagram')}
</TooltipContent>
</Tooltip>
</Controls>
{isLoadingDOM ? (
<Controls
position="top-center"
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Badge
variant="default"
className="bg-pink-600 text-white"
>
{t('loading_diagram')}
</Badge>
</Controls>
) : null}
{!isDesktop ? (
<Controls
position="bottom-left"
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Button
className="size-11 bg-pink-600 p-2 hover:bg-pink-500"
onClick={showSidePanel}
>
<Pencil />
</Button>
</Controls>
) : null}
<Controls
position={isDesktop ? 'bottom-center' : 'top-center'}
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Toolbar />
</Controls>
<MiniMap
style={{
width: isDesktop ? 100 : 60,
height: isDesktop ? 100 : 60,
<CanvasContextMenu>
<div className="relative flex h-full">
<ReactFlow
colorMode={effectiveTheme}
className="canvas-cursor-default nodes-animated"
nodes={nodes}
edges={edges}
onNodesChange={onNodesChangeHandler}
onEdgesChange={onEdgesChangeHandler}
maxZoom={5}
minZoom={0.1}
onConnect={onConnectHandler}
proOptions={{
hideAttribution: true,
}}
/>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
/>
</ReactFlow>
<MarkerDefinitions />
</div>
fitView={false}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{
animated: false,
type: 'table-edge',
}}
panOnScroll={scrollAction === 'pan'}
>
<Controls
position="top-left"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
className="size-8 p-1 shadow-none"
onClick={showReorderConfirmation}
>
<LayoutGrid className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t('toolbar.reorder_diagram')}
</TooltipContent>
</Tooltip>
</Controls>
{isLoadingDOM ? (
<Controls
position="top-center"
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Badge
variant="default"
className="bg-pink-600 text-white"
>
{t('loading_diagram')}
</Badge>
</Controls>
) : null}
{!isDesktop ? (
<Controls
position="bottom-left"
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Button
className="size-11 bg-pink-600 p-2 hover:bg-pink-500"
onClick={showSidePanel}
>
<Pencil />
</Button>
</Controls>
) : null}
<Controls
position={isDesktop ? 'bottom-center' : 'top-center'}
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Toolbar />
</Controls>
<MiniMap
style={{
width: isDesktop ? 100 : 60,
height: isDesktop ? 100 : 60,
}}
/>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
/>
</ReactFlow>
<MarkerDefinitions />
</div>
</CanvasContextMenu>
);
};

View File

@@ -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<EdgeProps<TableEdgeType>> = ({
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<EdgeProps<TableEdgeType>> = ({
]
);
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 (
<>
<path

View File

@@ -1,217 +0,0 @@
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';
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<NodeProps<TableNodeType>> = ({
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 (
<div
className={`flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 ${selected ? 'border-pink-600' : 'border-slate-500 dark:border-slate-700'} rounded-lg shadow-sm`}
onClick={(e) => {
if (e.detail === 2) {
openTableInEditor();
}
}}
>
<NodeResizer
isVisible={focused}
lineClassName="!border-none !w-2"
minWidth={MIN_TABLE_SIZE}
maxWidth={MAX_TABLE_SIZE}
shouldResize={(event) => event.dy === 0}
handleClassName="!hidden"
/>
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: table.color }}
></div>
<div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
<Label className="truncate text-sm font-bold">
{table.name}
</Label>
</div>
<div className="hidden shrink-0 flex-row group-hover:flex">
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={openTableInEditor}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={
table.width !== MAX_TABLE_SIZE
? expandTable
: shrinkTable
}
>
{table.width !== MAX_TABLE_SIZE ? (
<ChevronsLeftRight className="size-4" />
) : (
<ChevronsRightLeft className="size-4" />
)}
</Button>
</div>
</div>
<div
className="transition-[max-height] duration-200 ease-in-out"
style={{
maxHeight: expanded
? `${table.fields.length * 2}rem` // h-8 per field
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
}}
>
{table.fields.map((field: DBField) => (
<TableNodeField
key={field.id}
focused={focused}
tableNodeId={id}
field={field}
highlighted={selectedEdges.some(
(edge) =>
edge.data?.relationship.sourceFieldId ===
field.id ||
edge.data?.relationship.targetFieldId ===
field.id
)}
visible={visibleFields.includes(field)}
/>
))}
</div>
{table.fields.length > TABLE_MINIMIZED_FIELDS && (
<div
className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={toggleExpand}
>
{expanded ? (
<>
<ChevronUp className="mr-1 size-3.5" />
{t('show_less')}
</>
) : (
<>
<ChevronDown className="mr-1 size-3.5" />
{t('show_more')}
</>
)}
</div>
)}
</div>
);
};

View File

@@ -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<TableNodeContextMenuProps>
> = ({ 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 (
<ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={editTableHandler}>
{t('table_node_context_menu.edit_table')}
</ContextMenuItem>
<ContextMenuItem onClick={removeTableHandler}>
{t('table_node_context_menu.delete_table')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View File

@@ -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<NodeProps<TableNodeType>> = ({
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 (
<TableNodeContextMenu table={table}>
<div
className={`flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 ${selected ? 'border-pink-600' : 'border-slate-500 dark:border-slate-700'} rounded-lg shadow-sm`}
onClick={(e) => {
if (e.detail === 2) {
openTableInEditor();
}
}}
>
<NodeResizer
isVisible={focused}
lineClassName="!border-none !w-2"
minWidth={MIN_TABLE_SIZE}
maxWidth={MAX_TABLE_SIZE}
shouldResize={(event) => event.dy === 0}
handleClassName="!hidden"
/>
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: table.color }}
></div>
<div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
<Label className="truncate text-sm font-bold">
{table.name}
</Label>
</div>
<div className="hidden shrink-0 flex-row group-hover:flex">
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={openTableInEditor}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={
table.width !== MAX_TABLE_SIZE
? expandTable
: shrinkTable
}
>
{table.width !== MAX_TABLE_SIZE ? (
<ChevronsLeftRight className="size-4" />
) : (
<ChevronsRightLeft className="size-4" />
)}
</Button>
</div>
</div>
<div
className="transition-[max-height] duration-200 ease-in-out"
style={{
maxHeight: expanded
? `${table.fields.length * 2}rem` // h-8 per field
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
}}
>
{table.fields.map((field: DBField) => (
<TableNodeField
key={field.id}
focused={focused}
tableNodeId={id}
field={field}
highlighted={selectedEdges.some(
(edge) =>
edge.data?.relationship.sourceFieldId ===
field.id ||
edge.data?.relationship.targetFieldId ===
field.id
)}
visible={visibleFields.includes(field)}
/>
))}
</div>
{table.fields.length > TABLE_MINIMIZED_FIELDS && (
<div
className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={toggleExpand}
>
{expanded ? (
<>
<ChevronUp className="mr-1 size-3.5" />
{t('show_less')}
</>
) : (
<>
<ChevronDown className="mr-1 size-3.5" />
{t('show_more')}
</>
)}
</div>
)}
</div>
</TableNodeContextMenu>
);
};