tachybase_todo/packages/plugins/graph-collection-manager/src/client/GraphDrawPage.tsx
被雨水过滤的空气-Rain 4812cc5692
feat: support custome themes (#2228)
* feat: create custom-theme plugin

* feat: add custom-theme

* chore: add plugin name and description

* chore: add deps

* chore: optimize deps

* refactor: rename

* chore: add antd-token-previewer

* chore: add deps in plugin

* feat: add theme-editor

* feat: add theme-editor

* feat: add settings

* feat: add theme collection

* refactor: migration to the components folder

* feat: add ThemeList

* refactor: be better

* feat: export createStyles

* feat: implement ThemeCard (T-723)

* style: optimize style

* feat: add ThemeEditorProvider

* feat: add ToEditTheme

* chore: add isBuiltIn field

* feat: implement WYSIWYG

* refactor: migrate i18n

* feat: support to save config

* feat: add validation rule

* refactor: remove useless code

* refactor: optimize GlobalThemeProvider

* feat: add CurrentUserSettingsMenuProvider

* feat: support switching themes

* refactor: migrate CurrentUserProvider to app root

* feat: add InitializeTheme

* fix: fix preview demo

* fix: filter themes

* fix: not valid when editing theme

* fix: should restore the previous theme when closing theme editor

* fix: fix algorithm

* fix: the theme that was just saved should be applied

* refactor: loacl antd-token-previewer

* fix: should be based on the current theme when editing themes

* feat: support to edit theme

* perf: reduce executions

* feat: add animation

* fix: fix the type error of useRequest

* feat: remove built-in themes

* chore: reinstall deps

* fix: fix version of deps

* fix: delete client.d.ts to fix build error

* chore: reinstall deps

* fix: fix build

* fix: fix build

* fix: avoid build errors

* fix: fix crashing

* fix: use value instead of defaultValue

* fix: avoid error

* fix: avoid closure

* fix: fix build

* fix: fix style of login page

* refactor(page): fix style

* fix: fix style of PageHeader

* refactor: fix style of Drawer

* refactor: add FormDialog to loacl

* refactor: fix style of SchemaSettings.ModalItem

* refactor: fix style of pm/Card

* fix: fix text color of pm/Marketplace

* fix: fix table error

* refactor: fix style of collection-manager/summary

* refactor: fix style of fields drawer

* chore: reinstall deps

* fix: fix build

* fix: fix build of custom-theme

* fix: should export Plugin

* refactor: fix style of GraphDrawPage

* chore: upgrade plugin version

* refactor: fix style of Modal by using antd App

* refactor: fix style of FormDialog by using local version

* refactor(workflow): refactor style using antd-style in workflow

* fix(workflow): fix style of workflow

* fix: fix size

* refactor: add --nb-header-height

* feat: remove theme configuration from system settings

* refactor: migrate useUpdateThemeSettings to a new file

* refactor: rename theme to themeId

* feat: add updateSystemThemeSettings

* refactor: migrate utils function

* feat: use localStorage to avoid theme invalid in login page

* fix: fix style of login page

* fix: fix style of Drawer

* feat: optimize style of theme card

* fix: should use a empty object to reset theme

* fix: fix test of Page

* fix: fix test of Application

* fix: change backgroundColor of login page

* fix: fix all style of modal

* fix: fix gap between blocks (T-896)

* fix: fix color of font (T-905)

* fix: fix build

* fix: fix can not scroll in Drawer (T-897)

* fix: fix width of built-in plugins page (T-900)

* fix: fix style of import Modal (T-907)

* fix: fix style of calendar (T-908)

* fix: fix style of kanban (T-909)

* fix: fix style of Gantt (T-910)

* fix: fix style of Collapse (T-915)

* fix: fix style of mobile (T-916)

* fix: fix style of PageHeader (T-958)

* fix: optimize background color of Drawer

* fix: fix style of notification

* fix: fix T-1000

* fix: fix style of LinkageHeader (T-1003)

* fix: fix T-1004

* fix: fix style of scroll bar of chart (T-911)

* fix: fix style of workflow drawer (T-997)

* fix: fix T-922

* fix: fix T-924

* refactor: rename custom-theme to theme-editor

* fix: fix T-999

* fix: fix T-1011

* fix: fix error

* fix: optimize dark mode (T-921)

* fix: fix style of markdown (T-1020)

* fix: fix style of data template (T-1025)

* fix: fix style of rich text (T-1026)

* fix: fix style of a

* fix: fix style of XButton (T-1028)

* fix: fix T-1027

* fix: fix color of variable tag (T-1030)

* chore: translation

* feat: add a modal before create new theme (T-1024)

* feat: highlight card when editing theme (T-1031)

* feat: support to change theme name (T-1023)

* fix: api.auth.getOption('theme')

* fix: fix T-1032

* fix: fix feedback in feishu group

* refactor: code review

* fix: fix test

* chore: rename

* fix: useNotificationMiddleware

* refactor: revert

* fix: fix build

* fix: notification

* refactor: migrate CurrentUserProvider from Application to NocoBaseBuildInPlugin

* fix: fix test

* refactor: fix code review

* chore: avoid test error

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
2023-07-16 12:46:25 +08:00

1383 lines
49 KiB
TypeScript

import {
ApartmentOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
LineHeightOutlined,
MenuOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
import { Graph } from '@antv/x6';
import '@antv/x6-react-shape';
import { SchemaOptionsContext } from '@formily/react';
import {
APIClientProvider,
CollectionCategroriesContext,
CollectionCategroriesProvider,
CollectionManagerContext,
CollectionManagerProvider,
CurrentAppInfoContext,
SchemaComponent,
SchemaComponentOptions,
Select,
collection,
css,
cx,
useAPIClient,
useCollectionManager,
useCompile,
useCurrentAppInfo,
useGlobalTheme,
} from '@nocobase/client';
import { lodash } from '@nocobase/utils/client';
import { useFullscreen } from 'ahooks';
import { Button, ConfigProvider, Input, Layout, Menu, Popover, Switch, Tooltip } from 'antd';
import dagre from 'dagre';
import React, { createContext, forwardRef, useContext, useEffect, useLayoutEffect, useState } from 'react';
import { useAsyncDataSource, useCreateActionAndRefreshCM } from './action-hooks';
import { AddCollectionAction } from './components/AddCollectionAction';
import Entity from './components/Entity';
import { SimpleNodeView } from './components/ViewNode';
import useStyles from './style';
import {
formatData,
getChildrenCollections,
getDiffEdge,
getDiffNode,
getInheritCollections,
getPopupContainer,
useGCMTranslation,
} from './utils';
const { drop, groupBy, last, maxBy, minBy, take } = lodash;
const LINE_HEIGHT = 40;
const NODE_WIDTH = 250;
let targetGraph;
let targetNode;
const dir = 'TB'; // LR RL TB BT 横排
export enum DirectionType {
Both = 'both',
Target = 'target',
Source = 'source',
}
export enum ConnectionType {
Both = 'both',
Inherit = 'inherited',
Entity = 'entity',
}
const getGridData = (num, arr) => {
const newArr = [];
while (arr.length > 0 && num) {
newArr.push(arr.splice(0, num));
}
return newArr;
};
//初始布局
async function layout(createPositions) {
const { positions } = targetGraph;
let graphPositions = [];
const nodes: any[] = targetGraph.getNodes();
const edges = targetGraph.getEdges();
const g: any = new dagre.graphlib.Graph();
g.setGraph({ rankdir: dir, nodesep: 50, edgesep: 50, rankSep: 50, align: 'DL', controlPoints: true });
g.setDefaultEdgeLabel(() => ({}));
nodes.forEach((node, i) => {
const width = NODE_WIDTH;
const height = node.getPorts().length * 32 + 30;
g.setNode(node.id, { width, height });
});
dagre.layout(g);
targetGraph.freeze();
const dNodes = getGridData(15, g.nodes());
dNodes.forEach((arr, row) => {
arr.forEach((id, index) => {
const node = targetGraph.getCell(id);
const col = index % 15;
if (node) {
const targetPosition =
(positions &&
positions.find((v) => {
return v.collectionName === node.store.data.name;
})) ||
{};
const calculatedPosition = { x: col * 325 + 50, y: row * 400 + 60 };
node.position(targetPosition.x || calculatedPosition.x, targetPosition.y || calculatedPosition.y);
if (positions && !positions.find((v) => v.collectionName === node.store.data.name)) {
// 位置表中没有的表都自动保存
graphPositions.push({
collectionName: node.store.data.name,
x: calculatedPosition.x,
y: calculatedPosition.y,
});
}
}
});
});
edges.forEach((edge) => {
optimizeEdge(edge);
});
targetGraph.unfreeze();
if (targetNode) {
typeof targetNode === 'string'
? targetGraph.positionCell(last(nodes), 'top', { padding: 100 })
: targetGraph.positionCell(targetNode, 'top', { padding: 100 });
} else {
targetGraph.positionCell(nodes[0], 'top-left', { padding: 100 });
}
if (graphPositions.length > 0) {
await createPositions(graphPositions);
graphPositions = [];
}
}
function optimizeEdge(edge) {
const {
store: {
data: { connectionType },
},
} = edge;
const source = edge.getSource();
const target = edge.getTarget();
const sorceNodeX = targetGraph.getCell(source.cell).position().x;
const targeNodeX = targetGraph.getCell(target.cell).position().x;
const leftAnchor = connectionType
? {
name: 'topLeft',
args: {
dy: -20,
},
}
: {
name: 'left',
};
const rightAnchor = connectionType
? {
name: 'topRight',
args: {
dy: -20,
},
}
: 'right';
const router = connectionType ? 'normal' : 'er';
const vertices = edge.getVertices();
vertices.forEach(() => {
return edge.removeVertexAt(0);
});
if (sorceNodeX - 100 > targeNodeX) {
edge.setSource({
cell: source.cell,
port: source.port,
anchor: leftAnchor,
});
edge.setTarget({
cell: target.cell,
port: target.port,
anchor: rightAnchor,
});
edge.setRouter(router, {
direction: 'H',
});
} else if (Math.abs(sorceNodeX - targeNodeX) < 100) {
const sourceCell = targetGraph.getCell(source.cell);
const targetCell = targetGraph.getCell(target.cell);
edge.setSource({
cell: source.cell,
port: source.port,
anchor: leftAnchor,
});
edge.setTarget({
cell: target.cell,
port: target.port,
anchor: leftAnchor,
});
if (connectionType) {
edge.setVertices([
{ x: sourceCell.position().x - 30, y: sourceCell.position().y + 20 },
{ x: targetCell.position().x - 30, y: targetCell.position().y + 20 },
]);
edge.setRouter('normal');
} else {
edge.setRouter('oneSide', { side: 'left' });
}
} else {
edge.setSource({
cell: source.cell,
port: source.port,
anchor: rightAnchor,
});
edge.setTarget({
cell: target.cell,
port: target.port,
anchor: leftAnchor,
});
edge.setRouter(router, {
direction: 'H',
});
}
}
function getNodes(nodes) {
targetGraph.addNodes(nodes);
}
function getEdges(edges) {
edges.forEach((item) => {
if (item.source && item.target) {
targetGraph.addEdge({
...item,
connector: {
name: 'normal',
zIndex: 1000,
},
});
}
});
}
const CollapsedContext = createContext<any>({});
const formatNodeData = () => {
const layoutNodes = [];
const edges = targetGraph.getEdges();
const nodes = targetGraph.getNodes();
edges.forEach((edge) => {
layoutNodes.push(edge.getSourceCellId());
layoutNodes.push(edge.getTargetCellId());
});
const nodeGroup = groupBy(nodes, (v) => {
if (layoutNodes.includes(v.id)) {
return 'linkNodes';
} else {
return 'rawNodes';
}
});
return nodeGroup;
};
//自动布局
const handelResetLayout = () => {
const { linkNodes = [], rawNodes } = formatNodeData();
const { positions } = targetGraph;
const nodes = linkNodes.concat(rawNodes);
const edges = targetGraph.getEdges();
const g = new dagre.graphlib.Graph();
let alternateNum;
let rawEntity;
let num;
let minX;
let maxY;
const updatePositionData = [];
g.setGraph({ rankdir: 'TB', nodesep: 50, edgesep: 50, rankSep: 50, align: 'DL', controlPoints: true });
const width = 250;
const height = 400;
nodes.forEach((node) => {
g.setNode(node.id, { width, height });
});
edges.forEach((edge) => {
const source = edge.getSource();
const target = edge.getTarget();
g.setEdge(source.cell, target.cell, {});
});
dagre.layout(g);
targetGraph.freeze();
const gNodes = g.nodes();
const nodeWithEdges = take(gNodes, linkNodes.length);
const nodeWithoutEdges = drop(gNodes, linkNodes.length);
nodeWithEdges.forEach((id) => {
const node = targetGraph.getCell(id);
const positionId = positions.find((v) => v.collectionName === node.id)?.id;
if (node) {
const pos = g.node(id);
updatePositionData.push({ id: positionId, x: pos.x, y: pos.y });
node.position(pos?.x, pos?.y);
}
});
if (nodeWithEdges.length) {
maxY = targetGraph
.getCellById(
maxBy(nodeWithEdges, (k) => {
return targetGraph.getCellById(k).position().y;
}),
)
.position().y;
minX = targetGraph
.getCellById(
minBy(nodeWithEdges, (k) => {
return targetGraph.getCellById(k).position().x;
}),
)
.position().x;
const maxX = targetGraph
.getCellById(
maxBy(nodeWithEdges, (k) => {
return targetGraph.getCellById(k).position().x;
}),
)
.position().x;
const yNodes = nodeWithEdges.filter((v) => {
return Math.abs(targetGraph.getCellById(v).position().y - maxY) < 50;
});
const referenceNode: any = targetGraph
.getCell(maxBy(yNodes, (k) => targetGraph.getCellById(k).position().x))
?.position();
num = Math.round(maxX / 320) || 1;
alternateNum = Math.floor((4500 - (maxX + 100 - referenceNode.x)) / 280);
rawEntity = getGridData(num, rawNodes);
if (alternateNum >= 1) {
const alternateNodes = take(nodeWithoutEdges, alternateNum);
rawEntity = getGridData(num, drop(nodeWithoutEdges, alternateNum));
alternateNodes.forEach((id, index) => {
const node = targetGraph.getCell(id);
if (node) {
const calculatedPosition = { x: referenceNode.x + 320 * index + 280, y: referenceNode.y };
node.position(calculatedPosition.x, calculatedPosition.y);
const positionId = positions.find((v) => v.collectionName === node.id)?.id;
updatePositionData.push({ id: positionId, x: calculatedPosition.x, y: calculatedPosition.y });
}
});
}
} else {
num = 15;
alternateNum = 0;
rawEntity = getGridData(15, rawNodes);
minX = 50;
maxY = 50;
}
rawEntity.forEach((arr, row) => {
arr.forEach((id, index) => {
const node = targetGraph.getCell(id);
const col = index % num;
if (node) {
const calculatedPosition = { x: col * 325 + minX, y: row * 300 + maxY + 300 };
node.position(calculatedPosition.x, calculatedPosition.y);
const positionId = positions.find((v) => v.collectionName === node.id)?.id;
updatePositionData.push({ id: positionId, x: calculatedPosition.x, y: calculatedPosition.y });
}
});
});
edges.forEach((edge) => {
optimizeEdge(edge);
});
targetGraph.unfreeze();
targetGraph.positionCell(nodes[0], 'top-left', { padding: 100 });
targetGraph.updatePositionAction(updatePositionData, true);
};
export const GraphDrawPage = React.memo(() => {
const { theme } = useGlobalTheme();
const { styles } = useStyles();
const options = useContext(SchemaOptionsContext);
const ctx = useContext(CollectionManagerContext);
const api = useAPIClient();
const compile = useCompile();
const { t } = useGCMTranslation();
const [collectionData, setCollectionData] = useState<any>([]);
const [collectionList, setCollectionList] = useState<any>([]);
const { refreshCM } = useCollectionManager();
const {
data: { database },
} = useCurrentAppInfo();
const categoryCtx = useContext(CollectionCategroriesContext);
const scope = { ...options?.scope };
const components = { ...options?.components };
const useSaveGraphPositionAction = async (data) => {
await api.resource('graphPositions').create({ values: data });
await refreshPositions();
};
const useUpdatePositionAction = async (data, isbatch = false) => {
if (isbatch) {
await api.resource('graphPositions').update({
values: data,
});
} else {
await api.resource('graphPositions').update({
filter: { collectionName: data.collectionName },
values: { ...data },
});
}
await refreshPositions();
};
const refreshPositions = async () => {
const { data } = await api.resource('graphPositions').list({ paginate: false });
targetGraph.positions = data.data;
return Promise.resolve();
};
const setTargetNode = (node) => {
targetNode = node;
if (node === 'destory') {
refreshPositions();
}
};
const refreshGM = async () => {
const data = await refreshCM();
targetGraph.collections = data;
targetGraph.updatePositionAction = useUpdatePositionAction;
const currentNodes = targetGraph.getNodes();
setCollectionData(data);
setCollectionList(data);
if (!currentNodes.length) {
renderInitGraphCollection(data);
} else {
renderDiffGraphCollection(data);
}
};
const initGraphCollections = () => {
targetGraph = new Graph({
container: document.getElementById('container')!,
moveThreshold: 0,
scroller: {
enabled: true,
pannable: true,
padding: { top: 0, left: 500, right: 300, bottom: 400 },
},
selecting: {
enabled: false,
multiple: true,
rubberband: true,
movable: true,
className: 'node-selecting',
modifiers: 'shift',
},
minimap: {
enabled: true,
container: document.getElementById('graph-minimap'),
width: 300,
height: 250,
padding: 10,
graphOptions: {
async: true,
getCellView(cell) {
if (cell.isNode()) {
return SimpleNodeView;
}
},
createCellView(cell) {
if (cell.isEdge()) {
return null;
}
},
},
},
connecting: {
anchor: {
name: 'midSide',
},
},
mousewheel: {
enabled: true,
modifiers: ['ctrl', 'meta'],
},
snapline: {
enabled: !0,
},
keyboard: {
enabled: false,
},
clipboard: {
enabled: false,
},
interacting: {
magnetConnectable: false,
},
async: true,
preventDefaultBlankAction: true,
});
targetGraph.connectionType = ConnectionType.Both;
targetGraph.direction = DirectionType.Target;
targetGraph.cacheCollection = {};
Graph.registerPortLayout(
'erPortPosition',
(portsPositionArgs) => {
return portsPositionArgs.map((_, index) => {
return {
position: {
x: 0,
y: (index + 1) * LINE_HEIGHT,
},
angle: 0,
};
});
},
true,
);
Graph.registerNode(
'er-rect',
{
inherit: 'react-shape',
component: (node) => (
<CurrentAppInfoContext.Provider value={database}>
<APIClientProvider apiClient={api}>
<SchemaComponentOptions inherit scope={scope} components={components}>
<CollectionCategroriesProvider {...categoryCtx}>
<CollectionManagerProvider
collections={targetGraph?.collections}
refreshCM={refreshGM}
interfaces={ctx.interfaces}
>
{/* TODO: 因为画布中的卡片是一次性注册进 Graph 的,这里的 theme 是存在闭包里的,因此当主题动态变更时,并不会触发卡片的重新渲染 */}
<ConfigProvider theme={theme}>
<div style={{ height: 'auto' }}>
<Entity node={node} setTargetNode={setTargetNode} targetGraph={targetGraph} />
</div>
</ConfigProvider>
</CollectionManagerProvider>
</CollectionCategroriesProvider>
</SchemaComponentOptions>
</APIClientProvider>
</CurrentAppInfoContext.Provider>
),
ports: {
groups: {
list: {
markup: [
{
tagName: 'rect',
selector: 'portBody',
},
],
attrs: {
portBody: {
width: NODE_WIDTH,
height: LINE_HEIGHT,
strokeWidth: 1,
// magnet: true,
visibility: 'hidden',
},
},
position: 'erPortPosition',
},
},
},
body: {
refWidth: 100,
refHeight: 100,
},
},
true,
);
targetGraph.on('edge:mouseleave', ({ e, edge: targetEdge }) => {
e.stopPropagation();
handleEdgeUnActive(targetEdge);
});
targetGraph.on('node:moved', ({ e, node }) => {
e.stopPropagation();
const connectEdges = targetGraph.getConnectedEdges(node);
const currentPosition = node.position();
const oldPosition = targetGraph.positions.find((v) => v.collectionName === node.store.data.name);
if (oldPosition) {
(oldPosition.x !== currentPosition.x || oldPosition.y !== currentPosition.y) &&
useUpdatePositionAction({
collectionName: node.store.data.name,
...currentPosition,
});
} else {
useSaveGraphPositionAction({
collectionName: node.store.data.name,
...currentPosition,
});
}
connectEdges.forEach((edge) => {
optimizeEdge(edge);
});
});
targetGraph.on('cell:mouseenter', ({ e, cell, edge }) => {
e.stopPropagation();
cell.toFront();
if (edge) {
handleEdgeActive(edge);
}
});
targetGraph.on('blank:click', (e) => {
if (targetGraph?.activeEdge) {
handleEdgeUnActive(targetGraph?.activeEdge);
}
targetGraph.collapseNodes?.map((v) => {
const node = targetGraph.getCell(Object.keys(v)[0]);
Object.values(v)[0] && node.setData({ collapse: false });
});
targetGraph.cleanSelection();
});
targetGraph.on('node:selected', ({ e, node }) => {
node.setProp({ select: true });
});
targetGraph.on('node:unselected', ({ e, node }) => {
node.setProp({ select: false });
});
};
const handleEdgeUnActive = (targetEdge) => {
targetGraph.activeEdge = null;
const { m2m, connectionType } = targetEdge.store?.data;
const m2mLineId = m2m?.find((v) => v !== targetEdge.id);
const m2mEdge = targetGraph.getCellById(m2mLineId);
const lightsOut = (edge) => {
const targeNode = targetGraph.getCellById(edge.store.data.target.cell);
const sourceNode = targetGraph.getCellById(edge.store.data.source.cell);
targeNode.setProp({ targetPort: false, associated: null });
sourceNode.setProp({ sourcePort: false, associated: null });
edge.setAttrs({
line: {
stroke: '#ddd',
targetMarker: connectionType === ConnectionType.Inherit ? { name: 'classic', fill: '#ddd' } : null,
},
});
edge.setLabels(
edge.getLabels().map((v) => {
return {
...v,
attrs: {
labelText: {
...v.attrs.labelText,
fill: 'rgba(0, 0, 0, 0.3)',
},
labelBody: {
...v.attrs.labelBody,
stroke: '#ddd',
},
},
};
}),
);
};
lightsOut(targetEdge);
m2mEdge && lightsOut(m2mEdge);
};
const handleEdgeActive = (targetEdge) => {
targetGraph.activeEdge = targetEdge;
const { associated, m2m, connectionType } = targetEdge.store?.data;
const m2mLineId = m2m?.find((v) => v !== targetEdge.id);
const m2mEdge = targetGraph.getCellById(m2mLineId);
const lightUp = (edge) => {
edge.toFront();
edge.setAttrs({
line: {
stroke: '#1890ff',
strokeWidth: 1,
textAnchor: 'middle',
textVerticalAnchor: 'middle',
sourceMarker: null,
targetMarker: connectionType === ConnectionType.Inherit ? { name: 'classic', fill: '#1890ff' } : null,
},
});
edge.setLabels(
edge.getLabels().map((v) => {
return {
...v,
attrs: {
labelText: {
...v.attrs.labelText,
fill: '#1890ff',
},
labelBody: {
...v.attrs.labelBody,
stroke: '#1890ff',
},
},
};
}),
);
const targeNode = targetGraph.getCellById(edge.store.data.target.cell);
const sourceNode = targetGraph.getCellById(edge.store.data.source.cell);
targeNode.toFront();
sourceNode.toFront();
targeNode.setProp({
targetPort: edge.store.data.target.port,
associated,
});
sourceNode.setProp({
sourcePort: edge.store.data.source.port,
associated,
});
};
lightUp(targetEdge);
m2mEdge && lightUp(m2mEdge);
};
// 首次渲染
const renderInitGraphCollection = (rawData) => {
const { nodesData, edgesData, inheritEdges } = formatData(rawData);
targetGraph.data = { nodes: nodesData, edges: edgesData };
getNodes(nodesData);
getEdges(edgesData);
getEdges(inheritEdges);
layout(useSaveGraphPositionAction);
};
// 增量渲染
const renderDiffGraphCollection = (rawData) => {
const { positions }: { positions: { x: number; y: number }[] } = targetGraph;
const { nodesData, edgesData, inheritEdges } = formatData(rawData);
const currentNodes = targetGraph.getNodes().map((v) => v.store.data);
const totalEdges = targetGraph.getEdges().map((v) => v.store.data);
const currentEdgesGroup = groupBy(totalEdges, (v) => {
if (v.connectionType) {
return 'currentInheritEdges';
} else {
return 'currentRelateEdges';
}
});
const diffNodes = getDiffNode(nodesData, currentNodes);
const diffEdges = getDiffEdge(edgesData, currentEdgesGroup.currentRelateEdges || []);
const diffInheritEdge = getDiffEdge(inheritEdges, currentEdgesGroup.currentInheritEdges || []);
diffNodes.forEach(({ status, node, port }) => {
const updateNode = targetGraph.getCellById(node.id);
switch (status) {
case 'add':
const maxY = maxBy(positions, 'y').y;
const yNodes = positions.filter((v) => {
return Math.abs(v.y - maxY) < 100;
});
let referenceNode: any = maxBy(yNodes, 'x');
let position;
if (referenceNode.x > 4500) {
const minX = minBy(positions, 'x').x;
referenceNode = minBy(yNodes, 'x');
position = { x: minX, y: referenceNode.y + 400 };
} else {
position = { x: referenceNode.x + 350, y: referenceNode.y };
}
targetNode = targetGraph.addNode({
...node,
position,
});
useSaveGraphPositionAction({
collectionName: node.name,
...position,
});
targetGraph && targetGraph.positionCell(targetNode, 'top', { padding: 200 });
break;
case 'insertPort':
updateNode.insertPort(port.index, port.port);
break;
case 'deletePort':
updateNode.removePort(port.id);
break;
case 'updateNode':
updateNode.setProp({ title: node.title });
break;
case 'delete':
targetGraph.removeCell(node.id);
default:
return null;
}
});
const renderDiffEdges = (data) => {
data.forEach(({ status, edge }) => {
switch (status) {
case 'add':
const newEdge = targetGraph.addEdge({
...edge,
});
optimizeEdge(newEdge);
break;
case 'delete':
targetGraph.removeCell(edge.id);
break;
default:
return null;
}
});
};
setTimeout(() => {
renderDiffEdges(diffEdges.concat(diffInheritEdge));
});
};
const handleSearchCollection = (e) => {
const value = e.target.value.toLowerCase();
if (value) {
const targetCollections = collectionData.filter((v) => {
const collectionTitle = compile(v.title).toLowerCase();
return collectionTitle.includes(value);
});
setCollectionList(targetCollections);
} else {
setCollectionList(collectionData);
}
};
// 处理不同方向的继承关系表
const hanleHighlightInheritedNode = (key, direction) => {
if (direction === DirectionType.Target) {
const INodes = getInheritCollections(targetGraph.collections, key);
INodes.forEach((v) => {
targetGraph.getCellById(v)?.setAttrs({
hightLight: true,
direction,
connectionType: ConnectionType.Inherit,
});
});
} else {
const INodes = getChildrenCollections(targetGraph.collections, key);
INodes.forEach((v) => {
targetGraph.getCellById(v.name)?.setAttrs({
hightLight: true,
direction,
connectionType: ConnectionType.Inherit,
});
});
}
};
// target index entity relation
const handelTargetIndexEntity: any = (key) => {
const node = targetGraph.getCellById(key);
targetGraph.cacheCollection[key] = true;
const connectedEdges = targetGraph.getConnectedEdges(node);
const visibleEdges = connectedEdges.filter((v) => !v.store.data?.connectionType && v.getTargetCellId() === key);
visibleEdges.forEach((v) => {
if (v.store.data.m2m) {
v.store.data.m2m.forEach((i) => {
const m2mEdge = targetGraph.getCellById(i);
if (m2mEdge.getTargetCellId() === key) {
const sourceId = m2mEdge.getSourceCellId();
const node = targetGraph.getCellById(sourceId);
if (!node.store.data.attrs?.hightLight) {
node.setAttrs({
hightLight: true,
direction: DirectionType.Target,
connectionType: ConnectionType.Entity,
});
handelTargetIndexEntity(sourceId);
}
}
});
}
const sourceId = v.getSourceCellId();
const node = targetGraph.getCellById(sourceId);
if (!node.store.data.attrs?.hightLight) {
node.setAttrs({
hightLight: true,
direction: DirectionType.Target,
connectionType: ConnectionType.Entity,
});
handelTargetIndexEntity(sourceId);
}
});
};
// source index entity relation
const handelSourceIndexEntity: any = (key) => {
const node = targetGraph.getCellById(key);
const connectedEdges = targetGraph.getConnectedEdges(node);
const visibleEdges = connectedEdges.filter((v) => !v.store.data?.connectionType && v.getSourceCellId() === key);
visibleEdges.forEach((v) => {
if (v.store.data.m2m) {
v.store.data.m2m.forEach((i) => {
const m2mEdge = targetGraph.getCellById(i);
if (m2mEdge.getSourceCellId() === key) {
const targetId = m2mEdge.getTargetCellId();
const node = targetGraph.getCellById(targetId);
if (!node.store.data.attrs?.hightLight) {
node.setAttrs({
hightLight: true,
direction: DirectionType.Source,
connectionType: ConnectionType.Entity,
});
handelSourceIndexEntity(targetId);
}
}
});
}
const targetId = v.getTargetCellId();
const node = targetGraph.getCellById(targetId);
if (!node.store.data.attrs?.hightLight) {
node.setAttrs({
hightLight: true,
direction: DirectionType.Source,
connectionType: ConnectionType.Entity,
});
handelSourceIndexEntity(targetId);
}
});
};
// 处理不同方向的实体关系表
const handleHighlightRelationNodes = (nodekey, direction) => {
if (direction === DirectionType.Target) {
handelTargetIndexEntity(nodekey);
} else {
handelSourceIndexEntity(nodekey);
}
};
const handleCleanHighlight = (key?, currentDirection?, currentConnectionType?) => {
const nodes = targetGraph.getNodes().filter((v) => v.store.data.attrs?.hightLight);
const length = nodes.length;
for (let i = 0; i < length; i++) {
const { direction, connectionType } = nodes[i].getAttrs();
const filterFlag = nodes[i].id !== key;
const directionFlag = key && targetGraph.filterConfig?.key === key ? direction !== currentDirection : true;
const renltionshipFlag =
key && targetGraph.filterConfig?.key === key ? connectionType !== currentConnectionType : true;
if (nodes[i].id !== key) {
setTimeout(() => {
filterFlag &&
(directionFlag || renltionshipFlag) &&
nodes[i].setAttrs({
hightLight: false,
});
}, 0);
}
}
};
const handleFiterCollections = (value) => {
const { connectionType, direction, filterConfig } = targetGraph;
const directionBothFlag1 = value === filterConfig?.key && direction === DirectionType.Both;
const relationshipBothFlag =
value === filterConfig?.key &&
(connectionType === ConnectionType.Both || connectionType === filterConfig.connectionType);
if (value) {
(!directionBothFlag1 || !relationshipBothFlag) && handleCleanHighlight(value, direction, connectionType);
targetNode = targetGraph.getCellById(value);
targetGraph.positionCell(targetNode, 'center', { padding: 0 });
targetNode.setAttrs({
hightLight: true,
connectionType: connectionType,
});
setTimeout(() => {
if ([ConnectionType.Entity, ConnectionType.Both].includes(connectionType)) {
if (direction === DirectionType.Both) {
handleHighlightRelationNodes(value, DirectionType.Target);
handleHighlightRelationNodes(value, DirectionType.Source);
} else {
direction === DirectionType.Target && handleHighlightRelationNodes(value, direction);
direction === DirectionType.Source && handleHighlightRelationNodes(value, direction);
}
}
if ([ConnectionType.Inherit, ConnectionType.Both].includes(connectionType)) {
if (direction === DirectionType.Both) {
hanleHighlightInheritedNode(value, DirectionType.Target);
hanleHighlightInheritedNode(value, DirectionType.Source);
} else {
hanleHighlightInheritedNode(value, direction);
}
}
targetGraph.filterConfig = {
key: value,
direction: direction,
connectionType,
};
}, 0);
} else {
handleCleanHighlight();
}
};
const handleSetRelationshipType = (type) => {
handleSetEdgeVisible(type);
};
const handleSetEdgeVisible = (type) => {
targetNode = null;
const edges = targetGraph.getEdges();
edges.forEach((v) => {
const {
store: {
data: { connectionType },
},
} = v;
if (type === ConnectionType.Entity) {
if (connectionType) {
v.setVisible(false);
} else {
v.setVisible(true);
}
} else if (type === ConnectionType.Inherit) {
if (!connectionType) {
v.setVisible(false);
} else {
v.setVisible(true);
}
} else {
v.setVisible(true);
}
});
};
useLayoutEffect(() => {
initGraphCollections();
return () => {
targetGraph.off('cell:mouseenter');
targetGraph.off('edge:mouseleave');
targetGraph.off('node:moved');
targetGraph.off('blank:click');
targetGraph = null;
targetNode = null;
};
}, []);
useEffect(() => {
refreshPositions().then(() => {
refreshGM();
});
}, []);
const loadCollections = async () => {
return targetGraph.collections?.map((collection: any) => ({
label: compile(collection.title),
value: collection.name,
}));
};
return (
<Layout>
<div className={styles.graphCollectionContainerClass}>
<CollectionManagerProvider collections={targetGraph?.collections} refreshCM={refreshGM}>
<CollapsedContext.Provider value={{ collectionList, handleSearchCollection }}>
<div className={cx(styles.collectionListClass)}>
<SchemaComponent
components={{
Select: (props) => (
<Select popupMatchSelectWidth={false} {...props} getPopupContainer={getPopupContainer} />
),
AddCollectionAction,
}}
schema={{
type: 'void',
properties: {
block1: {
type: 'void',
'x-collection': 'collections',
'x-decorator': 'ResourceActionProvider',
'x-decorator-props': {
collection,
request: {
resource: 'collections',
action: 'list',
params: {
pageSize: 50,
filter: {
inherit: false,
},
sort: ['sort'],
appends: [],
},
},
},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
fontSize: 16,
},
},
properties: {
create: {
type: 'void',
title: '{{ t("Create collection") }}',
'x-component': 'AddCollectionAction',
'x-component-props': {
type: 'primary',
},
},
fullScreen: {
type: 'void',
'x-component': 'Action',
'x-component-props': {
component: forwardRef(() => {
const [isFullscreen, { toggleFullscreen }] = useFullscreen(
document.getElementById('graph_container'),
);
return (
<Tooltip title={t('Full Screen')} getPopupContainer={getPopupContainer}>
<Button
onClick={() => {
toggleFullscreen();
}}
>
{isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</Button>
</Tooltip>
);
}),
useAction: () => {
return {
run() {},
};
},
},
},
collectionList: {
type: 'void',
'x-component': function Com() {
const { handleSearchCollection, collectionList } = useContext(CollapsedContext);
const [selectedKeys, setSelectKey] = useState([]);
const content = (
<div>
<Input
style={{ margin: '4px 0' }}
bordered={false}
placeholder={t('Collection Search')}
onChange={handleSearchCollection}
/>
<Menu
selectedKeys={selectedKeys}
selectable={true}
className={css`
.ant-menu-item {
height: 32px;
line-height: 32px;
}
`}
style={{ maxHeight: '70vh', overflowY: 'auto', border: 'none' }}
items={[
{ type: 'divider' },
...collectionList.map((v) => {
return {
key: v.name,
label: compile(v.title),
onClick: (e: any) => {
if (e.key !== selectedKeys[0]) {
setSelectKey([e.key]);
handleFiterCollections(e.key);
} else {
targetGraph.filterConfig = null;
handleFiterCollections(false);
setSelectKey([]);
}
},
};
}),
]}
/>
</div>
);
return (
<Popover
content={content}
autoAdjustOverflow
placement="bottomRight"
trigger={['click']}
getPopupContainer={getPopupContainer}
overlayClassName={css`
.ant-popover-inner-content {
padding: 0;
}
`}
>
<Button>
<MenuOutlined />
</Button>
</Popover>
);
},
'x-component-props': {
icon: 'MenuOutlined',
useAction: () => {
return {
run() {},
};
},
},
},
autoLayout: {
type: 'void',
'x-component': 'Action',
'x-component-props': {
component: forwardRef(() => {
return (
<Tooltip title={t('Auto layout')} getPopupContainer={getPopupContainer}>
<Button
onClick={() => {
handelResetLayout();
}}
>
<ApartmentOutlined />
</Button>
</Tooltip>
);
}),
useAction: () => {
return {
run() {},
};
},
},
},
connectionType: {
type: 'void',
'x-component': () => {
const menuItems = [
{
key: ConnectionType.Both,
label: 'All relationships',
},
{
key: ConnectionType.Entity,
label: 'Entity relationship only',
},
{
key: ConnectionType.Inherit,
label: 'Inheritance relationship only',
},
];
const content = (
<div>
<Menu
defaultSelectedKeys={[ConnectionType.Both]}
selectable={true}
className={css`
.ant-menu-item {
height: 32px;
line-height: 32px;
}
`}
style={{ maxHeight: '70vh', overflowY: 'auto', border: 'none' }}
items={[
{ type: 'divider' },
...menuItems.map((v) => {
return {
key: v.key,
label: t(v.label),
onClick: (e: any) => {
targetGraph.connectionType = v.key;
const { filterConfig } = targetGraph;
filterConfig && handleFiterCollections(filterConfig.key);
handleSetRelationshipType(v.key);
},
};
}),
]}
/>
</div>
);
return (
<Popover
content={content}
autoAdjustOverflow
placement="bottomRight"
trigger={['click']}
getPopupContainer={getPopupContainer}
overlayClassName={css`
.ant-popover-inner-content {
padding: 0;
}
`}
>
<Button>
<ShareAltOutlined />
</Button>
</Popover>
);
},
'x-component-props': {
icon: 'MenuOutlined',
useAction: () => {
return {
run() {},
};
},
},
},
direction: {
type: 'void',
'x-component': () => {
const menuItems = [
{
key: DirectionType.Both,
label: 'All directions',
},
{
key: DirectionType.Target,
label: 'Target index',
},
{
key: DirectionType.Source,
label: 'Source index',
},
];
const content = (
<div>
<Menu
defaultSelectedKeys={[DirectionType.Target]}
selectable={true}
className={css`
.ant-menu-item {
height: 32px;
line-height: 32px;
}
`}
style={{ maxHeight: '70vh', overflowY: 'auto', border: 'none' }}
items={[
{ type: 'divider' },
...menuItems.map((v) => {
return {
key: v.key,
label: t(v.label),
onClick: (e: any) => {
targetGraph.direction = v.key;
const { filterConfig } = targetGraph;
if (filterConfig) {
handleFiterCollections(filterConfig.key);
}
},
};
}),
]}
/>
</div>
);
return (
<Popover
content={content}
autoAdjustOverflow
placement="bottomRight"
trigger={['click']}
getPopupContainer={getPopupContainer}
overlayClassName={css`
.ant-popover-inner-content {
padding: 0;
}
`}
>
<Button>
<LineHeightOutlined />
</Button>
</Popover>
);
},
},
selectMode: {
type: 'void',
'x-component': () => {
return (
<Tooltip title={t('Selection')}>
<Switch
onChange={(value) => {
targetGraph.toggleSelection();
}}
/>
</Tooltip>
);
},
},
},
},
},
},
},
}}
scope={{
useAsyncDataSource,
loadCollections,
useCreateActionAndRefreshCM: () => useCreateActionAndRefreshCM(setTargetNode),
enableInherits: database?.dialect === 'postgres',
}}
/>
</div>
</CollapsedContext.Provider>
</CollectionManagerProvider>
<div id="container" style={{ width: '100vw', height: '100vh' }}></div>
<div
id="graph-minimap"
className={styles.graphMinimap}
style={{ width: '300px', height: '250px', right: '10px', bottom: '20px', position: 'fixed' }}
></div>
</div>
</Layout>
);
});
GraphDrawPage.displayName = 'GraphDrawPage';