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,
};
}, [getInterface, items, record]);
return (
record.template !== 'view' && (
<RecordProvider record={record}>

View File

@ -1,19 +1,13 @@
import {
ApartmentOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
LineHeightOutlined,
MenuOutlined,
ShareAltOutlined,
} from '@ant-design/icons';
import { ApartmentOutlined } from '@ant-design/icons';
import { Graph } from '@antv/x6';
import { MiniMap } from '@antv/x6-plugin-minimap';
import { Scroller } from '@antv/x6-plugin-scroller';
import { Selection } from '@antv/x6-plugin-selection';
import { Snapline } from '@antv/x6-plugin-snapline';
import { register } from '@antv/x6-react-shape';
import { css, cx } from '@emotion/css';
import { cx } from '@emotion/css';
import { SchemaOptionsContext } from '@formily/react';
import { useSearchParams } from 'react-router-dom';
import {
APIClientProvider,
CollectionCategroriesContext,
@ -21,7 +15,6 @@ import {
CollectionManagerContext,
CollectionManagerProvider,
CurrentAppInfoContext,
Popover,
SchemaComponent,
SchemaComponentOptions,
Select,
@ -32,10 +25,9 @@ import {
useCurrentAppInfo,
useGlobalTheme,
} 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 { Button, ConfigProvider, Layout, Switch, Tooltip, App, Spin } 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';
@ -51,7 +43,12 @@ import {
getPopupContainer,
useGCMTranslation,
} 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 NODE_WIDTH = 250;
@ -72,7 +69,7 @@ export enum ConnectionType {
}
const getGridData = (num, arr) => {
const newArr = [];
while (arr.length > 0 && num) {
while (arr?.length > 0 && num) {
newArr.push(arr.splice(0, num));
}
return newArr;
@ -105,7 +102,7 @@ async function layout(createPositions) {
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);
if (positions && !positions.find((v) => v.collectionName === node.store.data.name)) {
// 位置表中没有的表都自动保存
@ -142,8 +139,8 @@ function optimizeEdge(edge) {
} = edge;
const source = edge.getSource();
const target = edge.getTarget();
const sorceNodeX = targetGraph.getCellById(source.cell).position().x;
const targeNodeX = targetGraph.getCellById(target.cell).position().x;
const sorceNodeX = targetGraph.getCellById(source.cell)?.position().x;
const targeNodeX = targetGraph.getCellById(target.cell)?.position().x;
const leftAnchor = connectionType
? {
name: 'topLeft',
@ -220,7 +217,7 @@ function optimizeEdge(edge) {
}
}
const CollapsedContext = createContext<any>({});
export const CollapsedContext = createContext<any>({});
const formatNodeData = () => {
const layoutNodes = [];
const edges = targetGraph.getEdges();
@ -239,10 +236,10 @@ const formatNodeData = () => {
return nodeGroup;
};
//自动布局
const handelResetLayout = () => {
const { linkNodes = [], rawNodes } = formatNodeData();
const handelResetLayout = (isTemporaryLayout?) => {
const { linkNodes = [], rawNodes = [] } = formatNodeData();
const { positions } = targetGraph;
const nodes = linkNodes.concat(rawNodes);
const nodes = linkNodes.concat(rawNodes || []);
const edges = targetGraph.getEdges();
const g = new dagre.graphlib.Graph();
let alternateNum;
@ -342,12 +339,16 @@ const handelResetLayout = () => {
optimizeEdge(edge);
});
targetGraph.positionCell(nodes[0], 'top-left', { padding: 100 });
if (!isTemporaryLayout) {
targetGraph.updatePositionAction(updatePositionData, true);
}
};
export const GraphDrawPage = React.memo(() => {
const { theme } = useGlobalTheme();
const { styles } = useStyles();
const [searchParams, setSearchParams] = useSearchParams();
const selectedCollections = searchParams.get('collections');
const options = useContext(SchemaOptionsContext);
const ctx = useContext(CollectionManagerContext);
const api = useAPIClient();
@ -364,11 +365,13 @@ export const GraphDrawPage = React.memo(() => {
const categoryCtx = useContext(CollectionCategroriesContext);
const scope = { ...options?.scope };
const components = { ...options?.components };
const saveGraphPositionAction = async (data) => {
await api.resource('graphPositions').create({ values: data });
await refreshPositions();
};
const updatePositionAction = async (data, isbatch = false) => {
if (!selectedCollections) {
if (isbatch) {
await api.resource('graphPositions').update({
values: data,
@ -380,6 +383,7 @@ export const GraphDrawPage = React.memo(() => {
});
}
await refreshPositions();
}
};
const refreshPositions = async () => {
const { data } = await api.resource('graphPositions').list({ paginate: false });
@ -400,7 +404,9 @@ export const GraphDrawPage = React.memo(() => {
setCollectionData(data);
setCollectionList(data);
if (!currentNodes.length) {
if (!selectedCollections) {
renderInitGraphCollection(data);
}
} else {
renderDiffGraphCollection(data);
}
@ -428,6 +434,9 @@ export const GraphDrawPage = React.memo(() => {
targetGraph.connectionType = ConnectionType.Both;
targetGraph.direction = DirectionType.Target;
targetGraph.cacheCollection = {};
targetGraph.onConnectionAssociation = handleConnectionAssociation;
targetGraph.onConnectionChilds = handleConnectionChilds;
targetGraph.onConnectionParents = handleConnectionParents;
Graph.registerPortLayout(
'erPortPosition',
(portsPositionArgs) => {
@ -503,16 +512,19 @@ export const GraphDrawPage = React.memo(() => {
});
targetGraph.use(
new Scroller({
autoResize: true,
enabled: 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(
new MiniMap({
container: document.getElementById('graph-minimap'),
width: 300,
height: 250,
height: 200,
padding: 10,
graphOptions: {
async: true,
@ -546,6 +558,10 @@ export const GraphDrawPage = React.memo(() => {
e.stopPropagation();
handleEdgeUnActive(targetEdge);
});
targetGraph.on('node:mouseleave', ({ e, node }) => {
e.stopPropagation();
node.setProp({ actived: false });
});
targetGraph.on('node:moved', ({ e, node }) => {
e.stopPropagation();
const connectEdges = targetGraph.getConnectedEdges(node);
@ -567,9 +583,13 @@ export const GraphDrawPage = React.memo(() => {
optimizeEdge(edge);
});
});
targetGraph.on('cell:mouseenter', ({ e, cell, edge }) => {
targetGraph.on('cell:mouseenter', ({ e, cell, edge, node }) => {
e.stopPropagation();
cell.toFront();
if (node) {
cell.setProp({ actived: true });
}
if (edge) {
handleEdgeActive(edge);
}
@ -580,7 +600,7 @@ export const GraphDrawPage = React.memo(() => {
}
targetGraph.collapseNodes?.map((v) => {
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();
});
@ -591,6 +611,30 @@ export const GraphDrawPage = React.memo(() => {
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) => {
targetGraph.activeEdge = null;
@ -680,11 +724,14 @@ export const GraphDrawPage = React.memo(() => {
lightUp(targetEdge);
m2mEdge && lightUp(m2mEdge);
};
// 首次渲染
// 全量渲染
const renderInitGraphCollection = (rawData) => {
targetGraph.clearCells();
const { nodesData, edgesData, inheritEdges } = formatData(rawData);
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);
};
@ -704,8 +751,8 @@ export const GraphDrawPage = React.memo(() => {
const diffNodes = getDiffNode(nodesData, currentNodes);
const diffEdges = getDiffEdge(edgesData, currentEdgesGroup.currentRelateEdges || []);
const diffInheritEdge = getDiffEdge(inheritEdges, currentEdgesGroup.currentInheritEdges || []);
const maxY = maxBy(positions, 'y').y;
const minX = minBy(positions, 'x').x;
const maxY = maxBy(positions, 'y')?.y;
const minX = minBy(positions, 'x')?.x;
const yNodes = positions.filter((v) => {
return Math.abs(v.y - maxY) < 100;
});
@ -726,10 +773,12 @@ export const GraphDrawPage = React.memo(() => {
...node,
position,
});
if (!selectedCollections) {
saveGraphPositionAction({
collectionName: node.name,
...position,
});
}
targetGraph && targetGraph.positionCell(targetNode, 'top', { padding: 200 });
break;
case 'insertPort':
@ -947,11 +996,16 @@ export const GraphDrawPage = React.memo(() => {
};
}, 0);
} else {
targetGraph.filterConfig = null;
handleCleanHighlight();
}
};
//切换连线类型
const handleSetRelationshipType = (type) => {
targetGraph.connectionType = type;
const { filterConfig } = targetGraph;
filterConfig && handleFiterCollections(filterConfig.key);
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(() => {
initGraphCollections();
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 () => {
return targetGraph.collections?.map((collection: any) => ({
label: compile(collection.title),
@ -1026,6 +1100,11 @@ export const GraphDrawPage = React.memo(() => {
<Select popupMatchSelectWidth={false} {...props} getPopupContainer={getPopupContainer} />
),
AddCollectionAction,
LocateCollectionAction,
SelectCollectionsAction,
DirectionAction,
ConnectorAction,
FullscreenAction,
}}
schema={{
type: 'void',
@ -1050,6 +1129,10 @@ export const GraphDrawPage = React.memo(() => {
},
},
properties: {
selectCollections: {
type: 'array',
'x-component': 'SelectCollectionsAction',
},
actions: {
type: 'void',
'x-component': 'ActionBar',
@ -1069,96 +1152,13 @@ export const GraphDrawPage = React.memo(() => {
},
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() {},
};
'x-component': 'FullscreenAction',
},
},
},
collectionList: {
locateCollection: {
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': 'LocateCollectionAction',
'x-component-props': {
handleFiterCollections,
icon: 'MenuOutlined',
useAction: () => {
return {
@ -1193,71 +1193,9 @@ export const GraphDrawPage = React.memo(() => {
},
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': 'ConnectorAction',
'x-component-props': {
onClick: handleSetRelationshipType,
icon: 'MenuOutlined',
useAction: () => {
return {
@ -1268,70 +1206,9 @@ export const GraphDrawPage = React.memo(() => {
},
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>
);
'x-component': 'DirectionAction',
'x-component-props': {
onClick: handleDirectionChange,
},
},
selectMode: {
@ -1364,7 +1241,7 @@ export const GraphDrawPage = React.memo(() => {
</div>
</CollapsedContext.Provider>
</CollectionManagerProvider>
<div id="container" style={{ width: '100vw', height: '100vh' }}></div>
<div id="container" style={{ width: '100vw' }}></div>
<div
id="graph-minimap"
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 { OverrideFieldAction } from './OverrideFieldAction';
import { ViewFieldAction } from './ViewFieldAction';
import { ConnectAssociationAction } from './ConnectAssociationAction';
import { ConnectChildAction } from './ConnectChildAction';
import { ConnectParentAction } from './ConnectParentAction';
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 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 {
data: { database },
} = useCurrentAppInfo();
@ -63,6 +71,7 @@ const OperationButton: any = React.memo((props: any) => {
OverrideFieldAction,
ViewFieldAction,
EditFieldAction,
ConnectAssociationAction,
...options.components,
}}
scope={{
@ -74,6 +83,7 @@ const OperationButton: any = React.memo((props: any) => {
useValuesFromRecord,
useUpdateCollectionActionAndRefreshCM,
isInheritField,
isShowAssocition,
...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 {
store: {
@ -218,20 +242,7 @@ const PopoverContent = React.memo((props: any) => {
}
};
return (
<Popover
content={CollectionConten(property)}
getPopupContainer={getPopupContainer}
mouseLeaveDelay={0}
zIndex={100}
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="body-item"
key={property.id}
@ -244,15 +255,31 @@ const PopoverContent = React.memo((props: any) => {
setIsHovered(true);
}}
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">
<Badge color={typeColor(property)} />
{compile(property.uiSchema?.title)}
</div>
</Popover>
<div className={`type field_type`}>{compile(getInterface(property.interface)?.title)}</div>
{isHovered && <OperationButton property={property} {...operatioBtnProps} />}
</div>
</Popover>
</div>
);
});
@ -292,6 +319,7 @@ const PortsCom = React.memo<any>(({ targetGraph, collectionData, setTargetNode,
loadCollections,
handelOpenPorts,
node,
targetGraph,
};
return (
<div className="body">
@ -339,7 +367,7 @@ const Entity: React.FC<{
const { node, setTargetNode, targetGraph } = props;
const {
store: {
data: { title, name, item, attrs, select },
data: { title, name, item, attrs, select, actived },
},
id,
} = node;
@ -403,15 +431,37 @@ const Entity: React.FC<{
loadCategories,
useAsyncDataSource,
enableInherits: database?.dialect === 'postgres',
actived: actived === true,
}}
components={{
EditOutlined,
EditCollectionAction,
ConnectChildAction,
ConnectParentAction,
...options.components,
}}
schema={{
type: 'object',
name: uid(),
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: {
type: 'void',
title: '{{ t("Edit") }}',
@ -430,7 +480,6 @@ const Entity: React.FC<{
component: DeleteOutlined,
icon: 'DeleteOutlined',
className: 'btn-del',
confirm: {
title: "{{t('Delete record')}}",
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};
}
}
.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;
height: 100%;
border-radius: ${token.borderRadiusLG}px;
@ -121,6 +143,14 @@ const useStyles = createStyles(({ token, css }) => {
.btn-view:hover {
background: ${token.colorBgTextHover};
}
.btn-assocition {
border-color: transparent;
color: ${token.colorPrimary};
width: 20px;
}
.btn-assocition:hover {
background: ${token.colorBgTextHover};
}
}
.field_type {
display: none;
@ -132,6 +162,7 @@ const useStyles = createStyles(({ token, css }) => {
white-space: nowrap;
overflow: hidden;
margin-left: 8px;
min-width: 50%;
}
.type {
@ -187,6 +218,7 @@ const useStyles = createStyles(({ token, css }) => {
`,
collectionListClass: css`
.nb-action-bar {
float: right;
position: fixed;
margin-top: 24px;
@ -212,10 +244,14 @@ const useStyles = createStyles(({ token, css }) => {
.ant-btn {
border: 0;
}
}
`,
graphCollectionContainerClass: css`
overflow: hidden;
#container {
height: 100% !important;
}
.x6-graph-scroller {
height: calc(100vh) !important;
width: calc(100vw) !important;

View File

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