feat(graph-collection-manager): display collections on demand (#2583)

* refactor: graph collection support on demand rendering collection

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: support connect parent or child

* refactor: code improve

* refactor: code improve

* style: style improve

* style: style improve

* refactor: code improve

* refactor: code improve

* refactor: code improve
This commit is contained in:
katherinehhh 2023-09-06 09:43:26 +08:00 committed by GitHub
parent d6a2ab4b61
commit eeb3adf928
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 619 additions and 311 deletions

View File

@ -282,7 +282,6 @@ export const AddFieldAction = (props) => {
items, items,
}; };
}, [getInterface, items, record]); }, [getInterface, items, record]);
return ( return (
record.template !== 'view' && ( record.template !== 'view' && (
<RecordProvider record={record}> <RecordProvider record={record}>

View File

@ -1,19 +1,13 @@
import { import { ApartmentOutlined } from '@ant-design/icons';
ApartmentOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
LineHeightOutlined,
MenuOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
import { Graph } from '@antv/x6'; import { Graph } from '@antv/x6';
import { MiniMap } from '@antv/x6-plugin-minimap'; import { MiniMap } from '@antv/x6-plugin-minimap';
import { Scroller } from '@antv/x6-plugin-scroller'; import { Scroller } from '@antv/x6-plugin-scroller';
import { Selection } from '@antv/x6-plugin-selection'; import { Selection } from '@antv/x6-plugin-selection';
import { Snapline } from '@antv/x6-plugin-snapline'; import { Snapline } from '@antv/x6-plugin-snapline';
import { register } from '@antv/x6-react-shape'; import { register } from '@antv/x6-react-shape';
import { css, cx } from '@emotion/css'; import { cx } from '@emotion/css';
import { SchemaOptionsContext } from '@formily/react'; import { SchemaOptionsContext } from '@formily/react';
import { useSearchParams } from 'react-router-dom';
import { import {
APIClientProvider, APIClientProvider,
CollectionCategroriesContext, CollectionCategroriesContext,
@ -21,7 +15,6 @@ import {
CollectionManagerContext, CollectionManagerContext,
CollectionManagerProvider, CollectionManagerProvider,
CurrentAppInfoContext, CurrentAppInfoContext,
Popover,
SchemaComponent, SchemaComponent,
SchemaComponentOptions, SchemaComponentOptions,
Select, Select,
@ -32,10 +25,9 @@ import {
useCurrentAppInfo, useCurrentAppInfo,
useGlobalTheme, useGlobalTheme,
} from '@nocobase/client'; } from '@nocobase/client';
import { useFullscreen } from 'ahooks';
import { App, Button, ConfigProvider, Input, Layout, Menu, Spin, Switch, Tooltip } from 'antd';
import dagre from 'dagre';
import lodash from 'lodash'; import lodash from 'lodash';
import { Button, ConfigProvider, Layout, Switch, Tooltip, App, Spin } from 'antd';
import dagre from 'dagre';
import React, { createContext, forwardRef, useContext, useEffect, useLayoutEffect, useState } from 'react'; import React, { createContext, forwardRef, useContext, useEffect, useLayoutEffect, useState } from 'react';
import { useAsyncDataSource, useCreateActionAndRefreshCM } from './action-hooks'; import { useAsyncDataSource, useCreateActionAndRefreshCM } from './action-hooks';
import { AddCollectionAction } from './components/AddCollectionAction'; import { AddCollectionAction } from './components/AddCollectionAction';
@ -51,7 +43,12 @@ import {
getPopupContainer, getPopupContainer,
useGCMTranslation, useGCMTranslation,
} from './utils'; } from './utils';
const { drop, groupBy, last, maxBy, minBy, take } = lodash; import { LocateCollectionAction } from './components/LocateCollectionAction';
import { SelectCollectionsAction } from './components/SelectCollectionsAction';
import { DirectionAction } from './components/DirectionAction';
import { ConnectorAction } from './components/ConnectorAction';
import { FullscreenAction } from './components/FullScreenAction';
const { drop, groupBy, last, maxBy, minBy, take, uniq } = lodash;
const LINE_HEIGHT = 40; const LINE_HEIGHT = 40;
const NODE_WIDTH = 250; const NODE_WIDTH = 250;
@ -72,7 +69,7 @@ export enum ConnectionType {
} }
const getGridData = (num, arr) => { const getGridData = (num, arr) => {
const newArr = []; const newArr = [];
while (arr.length > 0 && num) { while (arr?.length > 0 && num) {
newArr.push(arr.splice(0, num)); newArr.push(arr.splice(0, num));
} }
return newArr; return newArr;
@ -105,7 +102,7 @@ async function layout(createPositions) {
return v.collectionName === node.store.data.name; return v.collectionName === node.store.data.name;
})) || })) ||
{}; {};
const calculatedPosition = { x: col * 325 + 50, y: row * 400 + 60 }; const calculatedPosition = { x: col * 325 + 50, y: row * 400 + 100 };
node.position(targetPosition.x || calculatedPosition.x, targetPosition.y || calculatedPosition.y); node.position(targetPosition.x || calculatedPosition.x, targetPosition.y || calculatedPosition.y);
if (positions && !positions.find((v) => v.collectionName === node.store.data.name)) { if (positions && !positions.find((v) => v.collectionName === node.store.data.name)) {
// 位置表中没有的表都自动保存 // 位置表中没有的表都自动保存
@ -142,8 +139,8 @@ function optimizeEdge(edge) {
} = edge; } = edge;
const source = edge.getSource(); const source = edge.getSource();
const target = edge.getTarget(); const target = edge.getTarget();
const sorceNodeX = targetGraph.getCellById(source.cell).position().x; const sorceNodeX = targetGraph.getCellById(source.cell)?.position().x;
const targeNodeX = targetGraph.getCellById(target.cell).position().x; const targeNodeX = targetGraph.getCellById(target.cell)?.position().x;
const leftAnchor = connectionType const leftAnchor = connectionType
? { ? {
name: 'topLeft', name: 'topLeft',
@ -220,7 +217,7 @@ function optimizeEdge(edge) {
} }
} }
const CollapsedContext = createContext<any>({}); export const CollapsedContext = createContext<any>({});
const formatNodeData = () => { const formatNodeData = () => {
const layoutNodes = []; const layoutNodes = [];
const edges = targetGraph.getEdges(); const edges = targetGraph.getEdges();
@ -239,10 +236,10 @@ const formatNodeData = () => {
return nodeGroup; return nodeGroup;
}; };
//自动布局 //自动布局
const handelResetLayout = () => { const handelResetLayout = (isTemporaryLayout?) => {
const { linkNodes = [], rawNodes } = formatNodeData(); const { linkNodes = [], rawNodes = [] } = formatNodeData();
const { positions } = targetGraph; const { positions } = targetGraph;
const nodes = linkNodes.concat(rawNodes); const nodes = linkNodes.concat(rawNodes || []);
const edges = targetGraph.getEdges(); const edges = targetGraph.getEdges();
const g = new dagre.graphlib.Graph(); const g = new dagre.graphlib.Graph();
let alternateNum; let alternateNum;
@ -342,12 +339,16 @@ const handelResetLayout = () => {
optimizeEdge(edge); optimizeEdge(edge);
}); });
targetGraph.positionCell(nodes[0], 'top-left', { padding: 100 }); targetGraph.positionCell(nodes[0], 'top-left', { padding: 100 });
if (!isTemporaryLayout) {
targetGraph.updatePositionAction(updatePositionData, true); targetGraph.updatePositionAction(updatePositionData, true);
}
}; };
export const GraphDrawPage = React.memo(() => { export const GraphDrawPage = React.memo(() => {
const { theme } = useGlobalTheme(); const { theme } = useGlobalTheme();
const { styles } = useStyles(); const { styles } = useStyles();
const [searchParams, setSearchParams] = useSearchParams();
const selectedCollections = searchParams.get('collections');
const options = useContext(SchemaOptionsContext); const options = useContext(SchemaOptionsContext);
const ctx = useContext(CollectionManagerContext); const ctx = useContext(CollectionManagerContext);
const api = useAPIClient(); const api = useAPIClient();
@ -364,11 +365,13 @@ export const GraphDrawPage = React.memo(() => {
const categoryCtx = useContext(CollectionCategroriesContext); const categoryCtx = useContext(CollectionCategroriesContext);
const scope = { ...options?.scope }; const scope = { ...options?.scope };
const components = { ...options?.components }; const components = { ...options?.components };
const saveGraphPositionAction = async (data) => { const saveGraphPositionAction = async (data) => {
await api.resource('graphPositions').create({ values: data }); await api.resource('graphPositions').create({ values: data });
await refreshPositions(); await refreshPositions();
}; };
const updatePositionAction = async (data, isbatch = false) => { const updatePositionAction = async (data, isbatch = false) => {
if (!selectedCollections) {
if (isbatch) { if (isbatch) {
await api.resource('graphPositions').update({ await api.resource('graphPositions').update({
values: data, values: data,
@ -380,6 +383,7 @@ export const GraphDrawPage = React.memo(() => {
}); });
} }
await refreshPositions(); await refreshPositions();
}
}; };
const refreshPositions = async () => { const refreshPositions = async () => {
const { data } = await api.resource('graphPositions').list({ paginate: false }); const { data } = await api.resource('graphPositions').list({ paginate: false });
@ -400,7 +404,9 @@ export const GraphDrawPage = React.memo(() => {
setCollectionData(data); setCollectionData(data);
setCollectionList(data); setCollectionList(data);
if (!currentNodes.length) { if (!currentNodes.length) {
if (!selectedCollections) {
renderInitGraphCollection(data); renderInitGraphCollection(data);
}
} else { } else {
renderDiffGraphCollection(data); renderDiffGraphCollection(data);
} }
@ -428,6 +434,9 @@ export const GraphDrawPage = React.memo(() => {
targetGraph.connectionType = ConnectionType.Both; targetGraph.connectionType = ConnectionType.Both;
targetGraph.direction = DirectionType.Target; targetGraph.direction = DirectionType.Target;
targetGraph.cacheCollection = {}; targetGraph.cacheCollection = {};
targetGraph.onConnectionAssociation = handleConnectionAssociation;
targetGraph.onConnectionChilds = handleConnectionChilds;
targetGraph.onConnectionParents = handleConnectionParents;
Graph.registerPortLayout( Graph.registerPortLayout(
'erPortPosition', 'erPortPosition',
(portsPositionArgs) => { (portsPositionArgs) => {
@ -503,16 +512,19 @@ export const GraphDrawPage = React.memo(() => {
}); });
targetGraph.use( targetGraph.use(
new Scroller({ new Scroller({
autoResize: true,
enabled: true, enabled: true,
pannable: true, pannable: true,
padding: { top: 0, left: 500, right: 300, bottom: 400 }, pageVisible: true,
pageBreak: false,
padding: { top: 10, left: 500, right: 300, bottom: 300 },
}), }),
); );
targetGraph.use( targetGraph.use(
new MiniMap({ new MiniMap({
container: document.getElementById('graph-minimap'), container: document.getElementById('graph-minimap'),
width: 300, width: 300,
height: 250, height: 200,
padding: 10, padding: 10,
graphOptions: { graphOptions: {
async: true, async: true,
@ -546,6 +558,10 @@ export const GraphDrawPage = React.memo(() => {
e.stopPropagation(); e.stopPropagation();
handleEdgeUnActive(targetEdge); handleEdgeUnActive(targetEdge);
}); });
targetGraph.on('node:mouseleave', ({ e, node }) => {
e.stopPropagation();
node.setProp({ actived: false });
});
targetGraph.on('node:moved', ({ e, node }) => { targetGraph.on('node:moved', ({ e, node }) => {
e.stopPropagation(); e.stopPropagation();
const connectEdges = targetGraph.getConnectedEdges(node); const connectEdges = targetGraph.getConnectedEdges(node);
@ -567,9 +583,13 @@ export const GraphDrawPage = React.memo(() => {
optimizeEdge(edge); optimizeEdge(edge);
}); });
}); });
targetGraph.on('cell:mouseenter', ({ e, cell, edge }) => { targetGraph.on('cell:mouseenter', ({ e, cell, edge, node }) => {
e.stopPropagation(); e.stopPropagation();
cell.toFront(); cell.toFront();
if (node) {
cell.setProp({ actived: true });
}
if (edge) { if (edge) {
handleEdgeActive(edge); handleEdgeActive(edge);
} }
@ -580,7 +600,7 @@ export const GraphDrawPage = React.memo(() => {
} }
targetGraph.collapseNodes?.map((v) => { targetGraph.collapseNodes?.map((v) => {
const node = targetGraph.getCellById(Object.keys(v)[0]); const node = targetGraph.getCellById(Object.keys(v)[0]);
Object.values(v)[0] && node.setData({ collapse: false }); Object.values(v)[0] && node?.setData({ collapse: false });
}); });
targetGraph.cleanSelection(); targetGraph.cleanSelection();
}); });
@ -591,6 +611,30 @@ export const GraphDrawPage = React.memo(() => {
node.setProp({ select: false }); node.setProp({ select: false });
}); });
}; };
const handleConnectionAssociation = ({ target, through }) => {
const data = targetGraph.selectedCollections.split(',') || [];
data.push(target);
through && data.push(through);
const queryString = uniq(data).toString();
setSearchParams([['collections', queryString]]);
targetGraph.selectedCollections = queryString;
};
const handleConnectionChilds = (collections) => {
let data = targetGraph.selectedCollections.split(',') || [];
data = data.concat(collections);
const queryString = uniq(data).toString();
setSearchParams([['collections', queryString]]);
targetGraph.selectedCollections = queryString;
};
const handleConnectionParents = (collections) => {
console.log(collections);
let data = targetGraph.selectedCollections.split(',') || [];
data = data.concat(collections);
const queryString = uniq(data).toString();
setSearchParams([['collections', queryString]]);
targetGraph.selectedCollections = queryString;
};
const handleEdgeUnActive = (targetEdge) => { const handleEdgeUnActive = (targetEdge) => {
targetGraph.activeEdge = null; targetGraph.activeEdge = null;
@ -680,11 +724,14 @@ export const GraphDrawPage = React.memo(() => {
lightUp(targetEdge); lightUp(targetEdge);
m2mEdge && lightUp(m2mEdge); m2mEdge && lightUp(m2mEdge);
}; };
// 首次渲染 // 全量渲染
const renderInitGraphCollection = (rawData) => { const renderInitGraphCollection = (rawData) => {
targetGraph.clearCells();
const { nodesData, edgesData, inheritEdges } = formatData(rawData); const { nodesData, edgesData, inheritEdges } = formatData(rawData);
targetGraph.data = { nodes: nodesData, edges: edgesData }; targetGraph.data = { nodes: nodesData, edges: edgesData };
targetGraph.fromJSON({ nodes: nodesData, edges: inheritEdges.concat(edgesData) }); targetGraph.fromJSON({ nodes: nodesData });
targetGraph.addEdges(edgesData);
targetGraph.addEdges(inheritEdges);
layout(saveGraphPositionAction); layout(saveGraphPositionAction);
}; };
@ -704,8 +751,8 @@ export const GraphDrawPage = React.memo(() => {
const diffNodes = getDiffNode(nodesData, currentNodes); const diffNodes = getDiffNode(nodesData, currentNodes);
const diffEdges = getDiffEdge(edgesData, currentEdgesGroup.currentRelateEdges || []); const diffEdges = getDiffEdge(edgesData, currentEdgesGroup.currentRelateEdges || []);
const diffInheritEdge = getDiffEdge(inheritEdges, currentEdgesGroup.currentInheritEdges || []); const diffInheritEdge = getDiffEdge(inheritEdges, currentEdgesGroup.currentInheritEdges || []);
const maxY = maxBy(positions, 'y').y; const maxY = maxBy(positions, 'y')?.y;
const minX = minBy(positions, 'x').x; const minX = minBy(positions, 'x')?.x;
const yNodes = positions.filter((v) => { const yNodes = positions.filter((v) => {
return Math.abs(v.y - maxY) < 100; return Math.abs(v.y - maxY) < 100;
}); });
@ -726,10 +773,12 @@ export const GraphDrawPage = React.memo(() => {
...node, ...node,
position, position,
}); });
if (!selectedCollections) {
saveGraphPositionAction({ saveGraphPositionAction({
collectionName: node.name, collectionName: node.name,
...position, ...position,
}); });
}
targetGraph && targetGraph.positionCell(targetNode, 'top', { padding: 200 }); targetGraph && targetGraph.positionCell(targetNode, 'top', { padding: 200 });
break; break;
case 'insertPort': case 'insertPort':
@ -947,11 +996,16 @@ export const GraphDrawPage = React.memo(() => {
}; };
}, 0); }, 0);
} else { } else {
targetGraph.filterConfig = null;
handleCleanHighlight(); handleCleanHighlight();
} }
}; };
//切换连线类型
const handleSetRelationshipType = (type) => { const handleSetRelationshipType = (type) => {
targetGraph.connectionType = type;
const { filterConfig } = targetGraph;
filterConfig && handleFiterCollections(filterConfig.key);
handleSetEdgeVisible(type); handleSetEdgeVisible(type);
}; };
@ -982,6 +1036,14 @@ export const GraphDrawPage = React.memo(() => {
}); });
}; };
const handleDirectionChange = (direction) => {
targetGraph.direction = direction;
const { filterConfig } = targetGraph;
if (filterConfig) {
handleFiterCollections(filterConfig.key);
}
};
useLayoutEffect(() => { useLayoutEffect(() => {
initGraphCollections(); initGraphCollections();
return () => { return () => {
@ -1007,6 +1069,18 @@ export const GraphDrawPage = React.memo(() => {
}); });
}, []); }, []);
useEffect(() => {
if (selectedCollections && collectionList.length) {
const selectKeys = selectedCollections?.split(',');
const data = collectionList.filter((v) => selectKeys.includes(v.name));
renderInitGraphCollection(data);
handelResetLayout(true);
targetGraph.selectedCollections = selectedCollections;
} else {
!selectedCollections && renderInitGraphCollection(collections);
}
}, [searchParams, collectionList]);
const loadCollections = async () => { const loadCollections = async () => {
return targetGraph.collections?.map((collection: any) => ({ return targetGraph.collections?.map((collection: any) => ({
label: compile(collection.title), label: compile(collection.title),
@ -1026,6 +1100,11 @@ export const GraphDrawPage = React.memo(() => {
<Select popupMatchSelectWidth={false} {...props} getPopupContainer={getPopupContainer} /> <Select popupMatchSelectWidth={false} {...props} getPopupContainer={getPopupContainer} />
), ),
AddCollectionAction, AddCollectionAction,
LocateCollectionAction,
SelectCollectionsAction,
DirectionAction,
ConnectorAction,
FullscreenAction,
}} }}
schema={{ schema={{
type: 'void', type: 'void',
@ -1050,6 +1129,10 @@ export const GraphDrawPage = React.memo(() => {
}, },
}, },
properties: { properties: {
selectCollections: {
type: 'array',
'x-component': 'SelectCollectionsAction',
},
actions: { actions: {
type: 'void', type: 'void',
'x-component': 'ActionBar', 'x-component': 'ActionBar',
@ -1069,96 +1152,13 @@ export const GraphDrawPage = React.memo(() => {
}, },
fullScreen: { fullScreen: {
type: 'void', type: 'void',
'x-component': 'Action', 'x-component': 'FullscreenAction',
'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() {},
};
}, },
}, locateCollection: {
},
collectionList: {
type: 'void', type: 'void',
'x-component': function Com() { 'x-component': 'LocateCollectionAction',
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': { 'x-component-props': {
handleFiterCollections,
icon: 'MenuOutlined', icon: 'MenuOutlined',
useAction: () => { useAction: () => {
return { return {
@ -1193,71 +1193,9 @@ export const GraphDrawPage = React.memo(() => {
}, },
connectionType: { connectionType: {
type: 'void', type: 'void',
'x-component': () => { 'x-component': 'ConnectorAction',
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': { 'x-component-props': {
onClick: handleSetRelationshipType,
icon: 'MenuOutlined', icon: 'MenuOutlined',
useAction: () => { useAction: () => {
return { return {
@ -1268,70 +1206,9 @@ export const GraphDrawPage = React.memo(() => {
}, },
direction: { direction: {
type: 'void', type: 'void',
'x-component': () => { 'x-component': 'DirectionAction',
const menuItems = [ 'x-component-props': {
{ onClick: handleDirectionChange,
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: { selectMode: {
@ -1364,7 +1241,7 @@ export const GraphDrawPage = React.memo(() => {
</div> </div>
</CollapsedContext.Provider> </CollapsedContext.Provider>
</CollectionManagerProvider> </CollectionManagerProvider>
<div id="container" style={{ width: '100vw', height: '100vh' }}></div> <div id="container" style={{ width: '100vw' }}></div>
<div <div
id="graph-minimap" id="graph-minimap"
className={styles.graphMinimap} className={styles.graphMinimap}

View File

@ -0,0 +1,15 @@
import React from 'react';
import { Button } from 'antd';
import { BranchesOutlined } from '@ant-design/icons';
export const ConnectAssociationAction = (props) => {
const { targetGraph, item } = props;
return (
<BranchesOutlined
className="btn-assocition"
onClick={() => {
targetGraph.onConnectionAssociation(item);
}}
/>
);
};

View File

@ -0,0 +1,28 @@
import React from 'react';
import { Tooltip } from 'antd';
import { FallOutlined } from '@ant-design/icons';
import { useCollectionManager } from '@nocobase/client';
import { getPopupContainer, useGCMTranslation } from '../utils';
export const ConnectChildAction = (props) => {
const { targetGraph, item } = props;
const { t } = useGCMTranslation();
const { getChildrenCollections } = useCollectionManager();
const childs = getChildrenCollections(item.name);
const isShowChild = childs?.some(({ name }) => {
return !targetGraph.hasCell(name);
});
return isShowChild ? (
<Tooltip title={t('Show child')} getPopupContainer={getPopupContainer}>
<FallOutlined
className="btn-inheriedChild"
onClick={() => {
targetGraph.onConnectionChilds(childs.map((v) => v.name));
}}
/>
</Tooltip>
) : (
''
);
};

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Tooltip } from 'antd';
import { RiseOutlined } from '@ant-design/icons';
import { useCollectionManager } from '@nocobase/client';
import { getPopupContainer, useGCMTranslation } from '../utils';
export const ConnectParentAction = (props) => {
const { targetGraph, item } = props;
const { t } = useGCMTranslation();
const { getInheritCollections } = useCollectionManager();
const parents = getInheritCollections(item.name);
const isShowParent = parents?.some((name) => {
return !targetGraph.hasCell(name);
});
return isShowParent ? (
<Tooltip title={t('Show parent')} getPopupContainer={getPopupContainer}>
<RiseOutlined
className="btn-inheriedParent"
onClick={() => {
targetGraph.onConnectionParents(parents);
}}
/>
</Tooltip>
) : (
''
);
};

View File

@ -0,0 +1,71 @@
import React from 'react';
import { ShareAltOutlined } from '@ant-design/icons';
import { Menu, Popover, Button } from 'antd';
import { css } from '@emotion/css';
import { getPopupContainer, useGCMTranslation } from '../utils';
import { ConnectionType } from '../GraphDrawPage';
export const ConnectorAction = (props) => {
const { onClick } = props;
const { t } = useGCMTranslation();
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) => {
onClick?.(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>
);
};

View File

@ -0,0 +1,70 @@
import React from 'react';
import { Popover, Button, Menu } from 'antd';
import { css } from '@emotion/css';
import { LineHeightOutlined } from '@ant-design/icons';
import { getPopupContainer, useGCMTranslation } from '../utils';
import { DirectionType } from '../GraphDrawPage';
export const DirectionAction = (props) => {
const { onClick } = props;
const { t } = useGCMTranslation();
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: () => {
onClick?.(v.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>
);
};

View File

@ -35,11 +35,19 @@ import { EditFieldAction } from './EditFieldAction';
import { FieldSummary } from './FieldSummary'; import { FieldSummary } from './FieldSummary';
import { OverrideFieldAction } from './OverrideFieldAction'; import { OverrideFieldAction } from './OverrideFieldAction';
import { ViewFieldAction } from './ViewFieldAction'; import { ViewFieldAction } from './ViewFieldAction';
import { ConnectAssociationAction } from './ConnectAssociationAction';
import { ConnectChildAction } from './ConnectChildAction';
import { ConnectParentAction } from './ConnectParentAction';
const OperationButton: any = React.memo((props: any) => { const OperationButton: any = React.memo((props: any) => {
const { property, loadCollections, collectionData, setTargetNode, node, handelOpenPorts, title, name } = props; const { property, loadCollections, collectionData, setTargetNode, node, handelOpenPorts, title, name, targetGraph } =
props;
const isInheritField = !(property.collectionName !== name); const isInheritField = !(property.collectionName !== name);
const options = useContext(SchemaOptionsContext); const options = useContext(SchemaOptionsContext);
const isAssociationField = ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(property.type);
const isShowAssocition =
isAssociationField &&
!(property.through ? targetGraph.hasCell(property.through) : targetGraph.hasCell(property.target));
const { const {
data: { database }, data: { database },
} = useCurrentAppInfo(); } = useCurrentAppInfo();
@ -63,6 +71,7 @@ const OperationButton: any = React.memo((props: any) => {
OverrideFieldAction, OverrideFieldAction,
ViewFieldAction, ViewFieldAction,
EditFieldAction, EditFieldAction,
ConnectAssociationAction,
...options.components, ...options.components,
}} }}
scope={{ scope={{
@ -74,6 +83,7 @@ const OperationButton: any = React.memo((props: any) => {
useValuesFromRecord, useValuesFromRecord,
useUpdateCollectionActionAndRefreshCM, useUpdateCollectionActionAndRefreshCM,
isInheritField, isInheritField,
isShowAssocition,
...options.scope, ...options.scope,
}} }}
> >
@ -164,6 +174,20 @@ const OperationButton: any = React.memo((props: any) => {
}, },
}, },
}, },
connectAssociation: {
type: 'void',
'x-action': 'view',
'x-visible': '{{isShowAssocition}}',
'x-component': 'ConnectAssociationAction',
'x-component-props': {
item: {
...property,
title,
__parent: collectionData.current,
},
targetGraph,
},
},
}, },
}} }}
/> />
@ -173,7 +197,7 @@ const OperationButton: any = React.memo((props: any) => {
); );
}); });
const PopoverContent = React.memo((props: any) => { const PopoverContent = React.forwardRef((props: any, ref) => {
const { property, node, ...other } = props; const { property, node, ...other } = props;
const { const {
store: { store: {
@ -218,20 +242,7 @@ const PopoverContent = React.memo((props: any) => {
} }
}; };
return ( return (
<Popover
content={CollectionConten(property)}
getPopupContainer={getPopupContainer}
mouseLeaveDelay={0}
zIndex={100}
title={
<div> <div>
{compile(property.uiSchema?.title)}
<span style={{ color: '#ffa940', float: 'right' }}>{compile(getInterface(property.interface)?.title)}</span>
</div>
}
key={property.id}
placement="right"
>
<div <div
className="body-item" className="body-item"
key={property.id} key={property.id}
@ -244,15 +255,31 @@ const PopoverContent = React.memo((props: any) => {
setIsHovered(true); setIsHovered(true);
}} }}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
>
<Popover
content={CollectionConten(property)}
getPopupContainer={getPopupContainer}
mouseLeaveDelay={0}
title={
<div>
{compile(property.uiSchema?.title)}
<span style={{ color: '#ffa940', float: 'right' }}>
{compile(getInterface(property.interface)?.title)}
</span>
</div>
}
key={property.id}
placement="right"
> >
<div className="name"> <div className="name">
<Badge color={typeColor(property)} /> <Badge color={typeColor(property)} />
{compile(property.uiSchema?.title)} {compile(property.uiSchema?.title)}
</div> </div>
</Popover>
<div className={`type field_type`}>{compile(getInterface(property.interface)?.title)}</div> <div className={`type field_type`}>{compile(getInterface(property.interface)?.title)}</div>
{isHovered && <OperationButton property={property} {...operatioBtnProps} />} {isHovered && <OperationButton property={property} {...operatioBtnProps} />}
</div> </div>
</Popover> </div>
); );
}); });
@ -292,6 +319,7 @@ const PortsCom = React.memo<any>(({ targetGraph, collectionData, setTargetNode,
loadCollections, loadCollections,
handelOpenPorts, handelOpenPorts,
node, node,
targetGraph,
}; };
return ( return (
<div className="body"> <div className="body">
@ -339,7 +367,7 @@ const Entity: React.FC<{
const { node, setTargetNode, targetGraph } = props; const { node, setTargetNode, targetGraph } = props;
const { const {
store: { store: {
data: { title, name, item, attrs, select }, data: { title, name, item, attrs, select, actived },
}, },
id, id,
} = node; } = node;
@ -403,15 +431,37 @@ const Entity: React.FC<{
loadCategories, loadCategories,
useAsyncDataSource, useAsyncDataSource,
enableInherits: database?.dialect === 'postgres', enableInherits: database?.dialect === 'postgres',
actived: actived === true,
}} }}
components={{ components={{
EditOutlined, EditOutlined,
EditCollectionAction, EditCollectionAction,
ConnectChildAction,
ConnectParentAction,
...options.components, ...options.components,
}} }}
schema={{ schema={{
type: 'object', type: 'object',
name: uid(),
properties: { properties: {
connectParent: {
type: 'void',
'x-visible': '{{actived}}',
'x-component': 'ConnectParentAction',
'x-component-props': {
item: collectionData.current,
targetGraph,
},
},
connectChild: {
type: 'void',
'x-component': 'ConnectChildAction',
'x-component-props': {
item: collectionData.current,
targetGraph,
},
'x-visible': '{{actived}}',
},
update: { update: {
type: 'void', type: 'void',
title: '{{ t("Edit") }}', title: '{{ t("Edit") }}',
@ -430,7 +480,6 @@ const Entity: React.FC<{
component: DeleteOutlined, component: DeleteOutlined,
icon: 'DeleteOutlined', icon: 'DeleteOutlined',
className: 'btn-del', className: 'btn-del',
confirm: { confirm: {
title: "{{t('Delete record')}}", title: "{{t('Delete record')}}",
getContainer: getPopupContainer, getContainer: getPopupContainer,

View File

@ -0,0 +1,22 @@
import React, { forwardRef } from 'react';
import { Tooltip, Button } from 'antd';
import { FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { getPopupContainer, useGCMTranslation } from '../utils';
import { useFullscreen } from 'ahooks';
export const FullscreenAction = forwardRef(() => {
const { t } = useGCMTranslation();
const [isFullscreen, { toggleFullscreen }] = useFullscreen(document.getElementById('graph_container'));
return (
<Tooltip title={t('Full Screen')} getPopupContainer={getPopupContainer}>
<Button
onClick={() => {
toggleFullscreen();
}}
>
{isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</Button>
</Tooltip>
);
});

View File

@ -0,0 +1,73 @@
import React, { useState, useContext } from 'react';
import { Input, Menu, Popover, Button } from 'antd';
import { css } from '@emotion/css';
import { MenuOutlined } from '@ant-design/icons';
import { useCompile } from '@nocobase/client';
import { getPopupContainer, useGCMTranslation } from '../utils';
import { CollapsedContext } from '../GraphDrawPage';
export const LocateCollectionAction = (props) => {
const { handleFiterCollections } = props;
const { handleSearchCollection, collectionList } = useContext(CollapsedContext);
const [selectedKeys, setSelectKey] = useState([]);
const { t } = useGCMTranslation();
const compile = useCompile();
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 {
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>
);
};

View File

@ -0,0 +1,41 @@
import React, { useContext, useMemo } from 'react';
import { CollapsedContext } from '../GraphDrawPage';
import { Select, useCompile } from '@nocobase/client';
import { useSearchParams } from 'react-router-dom';
import { getPopupContainer } from '../utils';
export const SelectCollectionsAction = (props) => {
const { collectionList } = useContext(CollapsedContext);
const compile = useCompile();
const [searchParams, setSearchParams] = useSearchParams();
const initCollections = searchParams.get('collections');
const selectKeys = initCollections?.split(',');
const data = selectKeys?.filter((v) => collectionList.find((k) => k.name === v));
const collectionOptions = useMemo(() => {
return collectionList.map((v) => {
return {
label: compile(v.title),
value: v.name,
};
});
}, [collectionList]);
const handleChange = (values) => {
setSearchParams([['collections', values.toString()]]);
};
return (
<Select
value={data}
showSearch
getPopupContainer={getPopupContainer}
mode="multiple"
allowClear
onSearch={(value) => {
console.log(value);
}}
options={collectionOptions}
onChange={handleChange}
style={{ minWidth: 200, position: 'fixed', margin: '24px', zIndex: 1000, maxWidth: '60%' }}
/>
);
};

View File

@ -52,6 +52,28 @@ const useStyles = createStyles(({ token, css }) => {
background: ${token.colorBgTextHover}; background: ${token.colorBgTextHover};
} }
} }
.btn-inheriedParent {
background: ${token.colorInfoBg};
border-color: transparent;
color: ${token.colorInfo};
width: 20px;
height: 20px;
line-height: 25px;
&:hover {
background-color: ${token.colorInfoBgHover};
}
}
.btn-inheriedChild {
background: ${token.colorInfoBg};
border-color: transparent;
color: ${token.colorInfo};
width: 20px;
height: 20px;
margin: 0px 5px 4px;
&:hover {
background-color: ${token.colorInfoBgHover};
}
}
width: 250px; width: 250px;
height: 100%; height: 100%;
border-radius: ${token.borderRadiusLG}px; border-radius: ${token.borderRadiusLG}px;
@ -121,6 +143,14 @@ const useStyles = createStyles(({ token, css }) => {
.btn-view:hover { .btn-view:hover {
background: ${token.colorBgTextHover}; background: ${token.colorBgTextHover};
} }
.btn-assocition {
border-color: transparent;
color: ${token.colorPrimary};
width: 20px;
}
.btn-assocition:hover {
background: ${token.colorBgTextHover};
}
} }
.field_type { .field_type {
display: none; display: none;
@ -132,6 +162,7 @@ const useStyles = createStyles(({ token, css }) => {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
margin-left: 8px; margin-left: 8px;
min-width: 50%;
} }
.type { .type {
@ -187,6 +218,7 @@ const useStyles = createStyles(({ token, css }) => {
`, `,
collectionListClass: css` collectionListClass: css`
.nb-action-bar {
float: right; float: right;
position: fixed; position: fixed;
margin-top: 24px; margin-top: 24px;
@ -212,10 +244,14 @@ const useStyles = createStyles(({ token, css }) => {
.ant-btn { .ant-btn {
border: 0; border: 0;
} }
}
`, `,
graphCollectionContainerClass: css` graphCollectionContainerClass: css`
overflow: hidden; overflow: hidden;
#container {
height: 100% !important;
}
.x6-graph-scroller { .x6-graph-scroller {
height: calc(100vh) !important; height: calc(100vh) !important;
width: calc(100vw) !important; width: calc(100vw) !important;

View File

@ -63,14 +63,14 @@ export const formatData = (data) => {
?.fields.map((v) => { ?.fields.map((v) => {
return { ...v, sourceCollectionName: item.name }; return { ...v, sourceCollectionName: item.name };
}); });
return arr.concat(parentFields); return parentFields ? arr.concat(parentFields) : arr;
}, },
[], [],
); );
uniqBy(totalFields.concat(inheritedFields), 'name').forEach((field) => { uniqBy(totalFields.concat(inheritedFields), 'name').forEach((field) => {
field.uiSchema && field.uiSchema &&
ports.push({ ports.push({
id: field.key, id: field.name,
group: 'list', group: 'list',
...field, ...field,
}); });