forked from twentyhq/twenty
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Object Alternative view (twentyhq#5356)
Current state: <img width="704" alt="Bildschirmfoto 2024-05-11 um 17 57 33" src="https://github.com/twentyhq/twenty/assets/48770548/c979f6fd-083e-40d3-8dbb-c572229e0da3"> I have some things im not really happy with right now: * If I have different connections it would be weird to display a one_one or many_one connection differently * The edges overlay always at one hand at the source/target (also being a problem with the 3 dots vs 1 dot) * I would have to do 4 versions of the 3 dot marker variant as an svg with exactly the same width as the edges wich is not as easy as it seems :) * The initial layout is not really great - I know dagre or elkjs could solve this but maybe there is a better solution ... If someone has a good idea for one or more of the problems im happy to integrate them ;) --------- Co-authored-by: Félix Malfait <[email protected]>
- Loading branch information
1 parent
9080981
commit 1c867d4
Showing
13 changed files
with
1,166 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
246 changes: 246 additions & 0 deletions
246
...t/src/modules/settings/data-model/graph-overview/components/SettingsDataModelOverview.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,246 @@ | ||
import { useCallback } from 'react'; | ||
import ReactFlow, { | ||
applyEdgeChanges, | ||
applyNodeChanges, | ||
Background, | ||
Controls, | ||
EdgeChange, | ||
getIncomers, | ||
getOutgoers, | ||
NodeChange, | ||
useEdgesState, | ||
useNodesState, | ||
} from 'reactflow'; | ||
import styled from '@emotion/styled'; | ||
import { IconX } from 'twenty-ui'; | ||
|
||
import { SettingsDataModelOverviewEffect } from '@/settings/data-model/graph-overview/components/SettingsDataModelOverviewEffect'; | ||
import { SettingsDataModelOverviewObject } from '@/settings/data-model/graph-overview/components/SettingsDataModelOverviewObject'; | ||
import { SettingsDataModelOverviewRelationMarkers } from '@/settings/data-model/graph-overview/components/SettingsDataModelOverviewRelationMarkers'; | ||
import { calculateHandlePosition } from '@/settings/data-model/graph-overview/util/calculateHandlePosition'; | ||
import { Button } from '@/ui/input/button/components/Button'; | ||
import { isDefined } from '~/utils/isDefined'; | ||
|
||
import 'reactflow/dist/style.css'; | ||
|
||
const NodeTypes = { | ||
object: SettingsDataModelOverviewObject, | ||
}; | ||
const StyledContainer = styled.div` | ||
height: 100%; | ||
.has-many-edge { | ||
&.selected path.react-flow__edge-path { | ||
marker-end: url(#hasManySelected); | ||
stroke-width: 1.5; | ||
} | ||
} | ||
.has-many-edge--highlighted { | ||
path.react-flow__edge-path, | ||
path.react-flow__edge-interaction, | ||
path.react-flow__connection-path { | ||
stroke: ${({ theme }) => theme.tag.background.blue} !important; | ||
stroke-width: 1.5px; | ||
} | ||
} | ||
.has-many-edge-reversed { | ||
&.selected path.react-flow__edge-path { | ||
marker-end: url(#hasManyReversedSelected); | ||
stroke-width: 1.5; | ||
} | ||
} | ||
.has-many-edge-reversed--highlighted { | ||
path.react-flow__edge-path, | ||
path.react-flow__edge-interaction, | ||
path.react-flow__connection-path { | ||
stroke: ${({ theme }) => theme.tag.background.blue} !important; | ||
stroke-width: 1.5px; | ||
} | ||
} | ||
.react-flow__handle { | ||
border: 0 !important; | ||
background: transparent !important; | ||
width: 6px; | ||
height: 6px; | ||
min-height: 6px; | ||
min-width: 6px; | ||
pointer-events: none; | ||
} | ||
.left-handle { | ||
left: 0; | ||
top: 50%; | ||
transform: translateX(-50%) translateY(-50%); | ||
} | ||
.right-handle { | ||
right: 0; | ||
top: 50%; | ||
transform: translateX(50%) translateY(-50%); | ||
} | ||
.top-handle { | ||
top: 0; | ||
left: 50%; | ||
transform: translateX(-50%) translateY(-50%); | ||
} | ||
.bottom-handle { | ||
bottom: 0; | ||
left: 50%; | ||
transform: translateX(-50%) translateY(50%); | ||
} | ||
.react-flow__panel { | ||
display: flex; | ||
border-radius: ${({ theme }) => theme.border.radius.md}; | ||
box-shadow: unset; | ||
button { | ||
background: ${({ theme }) => theme.background.secondary}; | ||
border-bottom: none; | ||
fill: ${({ theme }) => theme.font.color.secondary}; | ||
} | ||
} | ||
.react-flow__node { | ||
z-index: -1 !important; | ||
} | ||
`; | ||
|
||
const StyledCloseButton = styled.div` | ||
position: absolute; | ||
top: ${({ theme }) => theme.spacing(3)}; | ||
left: ${({ theme }) => theme.spacing(3)}; | ||
z-index: 5; | ||
`; | ||
|
||
export const SettingsDataModelOverview = () => { | ||
const [nodes, setNodes] = useNodesState([]); | ||
const [edges, setEdges] = useEdgesState([]); | ||
|
||
const onNodesChange = useCallback( | ||
(changes: NodeChange[]) => | ||
setNodes((nds) => applyNodeChanges(changes, nds)), | ||
[setNodes], | ||
); | ||
const onEdgesChange = useCallback( | ||
(changes: EdgeChange[]) => | ||
setEdges((eds) => applyEdgeChanges(changes, eds)), | ||
[setEdges], | ||
); | ||
|
||
const handleNodesChange = useCallback( | ||
(nodeChanges: any[]) => { | ||
nodeChanges.forEach((nodeChange) => { | ||
const node = nodes.find((node) => node.id === nodeChange.id); | ||
if (!node) { | ||
return; | ||
} | ||
|
||
const incomingNodes = getIncomers(node, nodes, edges); | ||
const newXPos = | ||
'positionAbsolute' in nodeChange | ||
? nodeChange.positionAbsolute?.x | ||
: node.position.x || 0; | ||
|
||
incomingNodes.forEach((incomingNode) => { | ||
const edge = edges.find((edge) => { | ||
return edge.target === node.id && edge.source === incomingNode.id; | ||
}); | ||
|
||
if (isDefined(newXPos)) { | ||
setEdges((eds) => | ||
eds.map((ed) => { | ||
if (isDefined(edge) && ed.id === edge.id) { | ||
const sourcePosition = calculateHandlePosition( | ||
incomingNode.width as number, | ||
incomingNode.position.x, | ||
node.width as number, | ||
newXPos, | ||
'source', | ||
); | ||
const targetPosition = calculateHandlePosition( | ||
incomingNode.width as number, | ||
incomingNode.position.x, | ||
node.width as number, | ||
newXPos, | ||
'target', | ||
); | ||
const sourceHandle = `${edge.data.sourceField}-${sourcePosition}`; | ||
const targetHandle = `${edge.data.targetField}-${targetPosition}`; | ||
ed.sourceHandle = sourceHandle; | ||
ed.targetHandle = targetHandle; | ||
ed.markerEnd = 'marker'; | ||
ed.markerStart = 'marker'; | ||
} | ||
|
||
return ed; | ||
}), | ||
); | ||
} | ||
}); | ||
|
||
const outgoingNodes = getOutgoers(node, nodes, edges); | ||
outgoingNodes.forEach((targetNode) => { | ||
const edge = edges.find((edge) => { | ||
return edge.target === targetNode.id && edge.source === node.id; | ||
}); | ||
if (isDefined(newXPos)) { | ||
setEdges((eds) => | ||
eds.map((ed) => { | ||
if (isDefined(edge) && ed.id === edge.id) { | ||
const sourcePosition = calculateHandlePosition( | ||
node.width as number, | ||
newXPos, | ||
targetNode.width as number, | ||
targetNode.position.x, | ||
'source', | ||
); | ||
const targetPosition = calculateHandlePosition( | ||
node.width as number, | ||
newXPos, | ||
targetNode.width as number, | ||
targetNode.position.x, | ||
'target', | ||
); | ||
|
||
const sourceHandle = `${edge.data.sourceField}-${sourcePosition}`; | ||
const targetHandle = `${edge.data.targetField}-${targetPosition}`; | ||
|
||
ed.sourceHandle = sourceHandle; | ||
ed.targetHandle = targetHandle; | ||
ed.markerEnd = 'marker'; | ||
ed.markerStart = 'marker'; | ||
} | ||
|
||
return ed; | ||
}), | ||
); | ||
} | ||
}); | ||
}); | ||
|
||
onNodesChange(nodeChanges); | ||
}, | ||
[onNodesChange, setEdges, nodes, edges], | ||
); | ||
|
||
return ( | ||
<StyledContainer> | ||
<StyledCloseButton> | ||
<Button Icon={IconX} to="/settings/objects"></Button> | ||
</StyledCloseButton> | ||
<SettingsDataModelOverviewEffect | ||
setEdges={setEdges} | ||
setNodes={setNodes} | ||
/> | ||
<SettingsDataModelOverviewRelationMarkers /> | ||
<ReactFlow | ||
fitView | ||
nodes={nodes} | ||
edges={edges} | ||
onEdgesChange={onEdgesChange} | ||
nodeTypes={NodeTypes} | ||
onNodesChange={handleNodesChange} | ||
proOptions={{ hideAttribution: true }} | ||
> | ||
<Background /> | ||
<Controls /> | ||
</ReactFlow> | ||
</StyledContainer> | ||
); | ||
}; |
104 changes: 104 additions & 0 deletions
104
...modules/settings/data-model/graph-overview/components/SettingsDataModelOverviewEffect.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import { useEffect } from 'react'; | ||
import { Edge, Node } from 'reactflow'; | ||
import dagre from '@dagrejs/dagre'; | ||
import { useTheme } from '@emotion/react'; | ||
import { useRecoilValue } from 'recoil'; | ||
|
||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; | ||
import { isDefined } from '~/utils/isDefined'; | ||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; | ||
|
||
type SettingsDataModelOverviewEffectProps = { | ||
setEdges: (edges: Edge[]) => void; | ||
setNodes: (nodes: Node[]) => void; | ||
}; | ||
|
||
export const SettingsDataModelOverviewEffect = ({ | ||
setEdges, | ||
setNodes, | ||
}: SettingsDataModelOverviewEffectProps) => { | ||
const theme = useTheme(); | ||
const objectMetadataItems = useRecoilValue(objectMetadataItemsState); | ||
|
||
useEffect(() => { | ||
const items = objectMetadataItems.filter((x) => !x.isSystem); | ||
|
||
const g = new dagre.graphlib.Graph(); | ||
g.setGraph({ rankdir: 'LR' }); | ||
g.setDefaultEdgeLabel(() => ({})); | ||
|
||
const edges: Edge[] = []; | ||
const nodes = []; | ||
let i = 0; | ||
for (const object of items) { | ||
nodes.push({ | ||
id: object.namePlural, | ||
width: 220, | ||
height: 100, | ||
position: { x: i * 300, y: 0 }, | ||
data: object, | ||
type: 'object', | ||
}); | ||
g.setNode(object.namePlural, { width: 220, height: 100 }); | ||
|
||
for (const field of object.fields) { | ||
if ( | ||
isDefined(field.toRelationMetadata) && | ||
isDefined( | ||
items.find( | ||
(x) => x.id === field.toRelationMetadata?.fromObjectMetadata.id, | ||
), | ||
) | ||
) { | ||
const sourceObj = | ||
field.relationDefinition?.sourceObjectMetadata.namePlural; | ||
const targetObj = | ||
field.relationDefinition?.targetObjectMetadata.namePlural; | ||
|
||
edges.push({ | ||
id: `${sourceObj}-${targetObj}`, | ||
source: object.namePlural, | ||
sourceHandle: `${field.id}-right`, | ||
target: field.toRelationMetadata.fromObjectMetadata.namePlural, | ||
targetHandle: `${field.toRelationMetadata.fromFieldMetadataId}-left`, | ||
type: 'smoothstep', | ||
style: { | ||
strokeWidth: 1, | ||
stroke: theme.color.gray, | ||
}, | ||
markerEnd: 'marker', | ||
markerStart: 'marker', | ||
data: { | ||
sourceField: field.id, | ||
targetField: field.toRelationMetadata.fromFieldMetadataId, | ||
relation: field.toRelationMetadata.relationType, | ||
sourceObject: sourceObj, | ||
targetObject: targetObj, | ||
}, | ||
}); | ||
if (!isUndefinedOrNull(sourceObj) && !isUndefinedOrNull(targetObj)) { | ||
g.setEdge(sourceObj, targetObj); | ||
} | ||
} | ||
} | ||
i++; | ||
} | ||
|
||
dagre.layout(g); | ||
|
||
nodes.forEach((node) => { | ||
const nodeWithPosition = g.node(node.id); | ||
node.position = { | ||
// We are shifting the dagre node position (anchor=center center) to the top left | ||
// so it matches the React Flow node anchor point (top left). | ||
x: nodeWithPosition.x - node.width / 2, | ||
y: nodeWithPosition.y - node.height / 2, | ||
}; | ||
}); | ||
|
||
setNodes(nodes); | ||
setEdges(edges); | ||
}, [objectMetadataItems, setEdges, setNodes, theme]); | ||
|
||
return <></>; | ||
}; |
Oops, something went wrong.