Skip to content

Commit

Permalink
Add Object Alternative view (twentyhq#5356)
Browse files Browse the repository at this point in the history
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
brendanlaschke and FelixMalfait authored May 25, 2024
1 parent 9080981 commit 1c867d4
Show file tree
Hide file tree
Showing 13 changed files with 1,166 additions and 40 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@chakra-ui/accordion": "^2.3.0",
"@chakra-ui/system": "^2.6.0",
"@codesandbox/sandpack-react": "^2.13.5",
"@dagrejs/dagre": "^1.1.2",
"@docusaurus/core": "^3.1.0",
"@docusaurus/preset-classic": "^3.1.0",
"@emotion/react": "^11.11.1",
Expand Down Expand Up @@ -166,6 +167,7 @@
"react-router-dom": "^6.4.4",
"react-textarea-autosize": "^8.4.1",
"react-tooltip": "^5.13.1",
"reactflow": "^11.11.3",
"recoil": "^0.7.7",
"rehype-slug": "^6.0.0",
"remark-behead": "^3.1.0",
Expand Down Expand Up @@ -240,6 +242,7 @@
"@types/bytes": "^3.1.1",
"@types/chrome": "^0.0.267",
"@types/crypto-js": "^4.2.2",
"@types/dagre": "^0.7.52",
"@types/deep-equal": "^1.0.1",
"@types/express": "^4.17.13",
"@types/graphql-fields": "^1.3.6",
Expand Down
5 changes: 5 additions & 0 deletions packages/twenty-front/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { SettingsObjectEdit } from '~/pages/settings/data-model/SettingsObjectEd
import { SettingsObjectFieldEdit } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
import { SettingsObjectNewFieldStep1 } from '~/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep1';
import { SettingsObjectNewFieldStep2 } from '~/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2';
import { SettingsObjectOverview } from '~/pages/settings/data-model/SettingsObjectOverview';
import { SettingsObjects } from '~/pages/settings/data-model/SettingsObjects';
import { SettingsDevelopersApiKeyDetail } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail';
import { SettingsDevelopersApiKeysNew } from '~/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew';
Expand Down Expand Up @@ -200,6 +201,10 @@ const createRouter = (isBillingEnabled?: boolean) =>
path={SettingsPath.Objects}
element={<SettingsObjects />}
/>
<Route
path={SettingsPath.ObjectOverview}
element={<SettingsObjectOverview />}
/>
<Route
path={SettingsPath.ObjectDetail}
element={<SettingsObjectDetail />}
Expand Down
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>
);
};
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 <></>;
};
Loading

0 comments on commit 1c867d4

Please sign in to comment.