mirror of
https://github.com/chartdb/chartdb.git
synced 2026-01-08 21:00:02 -06:00
add context menu to table & canvas
This commit is contained in:
committed by
Guy Ben-Aharon
parent
c89d9af8d8
commit
7bc1eaa980
29
package-lock.json
generated
29
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
202
src/components/context-menu/context-menu.tsx
Normal file
202
src/components/context-menu/context-menu.tsx
Normal 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,
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
43
src/pages/editor-page/canvas/canvas-context-menu.tsx
Normal file
43
src/pages/editor-page/canvas/canvas-context-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
220
src/pages/editor-page/canvas/table-node/table-node.tsx
Normal file
220
src/pages/editor-page/canvas/table-node/table-node.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user