Add Object Alternative view (#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 <felix.malfait@gmail.com>
This commit is contained in:
brendanlaschke
2024-05-25 10:38:27 +02:00
committed by GitHub
parent 9080981990
commit 1c867d49a1
13 changed files with 1166 additions and 40 deletions

View File

@ -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';
@ -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 />}

View File

@ -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>
);
};

View File

@ -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 <></>;
};

View File

@ -0,0 +1,63 @@
import { Handle, Position } from 'reactflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useIcons } from 'twenty-ui';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
type ObjectFieldRowProps = {
field: FieldMetadataItem;
};
const StyledRow = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
position: relative;
width: 100%;
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
export const ObjectFieldRow = ({ field }: ObjectFieldRowProps) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { getIcon } = useIcons();
const theme = useTheme();
const relatedObjectId = field.relationDefinition?.targetObjectMetadata.id;
const relatedObject = objectMetadataItems.find(
(x) => x.id === relatedObjectId,
);
const Icon = getIcon(relatedObject?.icon);
return (
<StyledRow>
{Icon && <Icon size={theme.icon.size.md} />}
{capitalize(relatedObject?.namePlural ?? '')}
<Handle
type={field.toRelationMetadata ? 'source' : 'target'}
position={Position.Right}
id={`${field.id}-right`}
className={
field.fromRelationMetadata
? 'right-handle source-handle'
: 'right-handle target-handle'
}
/>
<Handle
type={field.toRelationMetadata ? 'source' : 'target'}
position={Position.Left}
id={`${field.id}-left`}
className={
field.fromRelationMetadata
? 'left-handle source-handle'
: 'left-handle target-handle'
}
/>
</StyledRow>
);
};

View File

@ -0,0 +1,142 @@
import { Link } from 'react-router-dom';
import { NodeProps } from 'reactflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconTag, useIcons } from 'twenty-ui';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { ObjectFieldRow } from '@/settings/data-model/graph-overview/components/SettingsDataModelOverviewField';
import { SettingsDataModelObjectTypeTag } from '@/settings/data-model/objects/SettingsDataModelObjectTypeTag';
import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel';
import { FieldMetadataType } from '~/generated/graphql';
import { capitalize } from '~/utils/string/capitalize';
import '@reactflow/node-resizer/dist/style.css';
type SettingsDataModelOverviewObjectProps = NodeProps<ObjectMetadataItem>;
const StyledNode = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
flex-direction: column;
width: 220px;
padding: ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(2)};
border: 1px solid ${({ theme }) => theme.border.color.medium};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledObjectName = styled.div`
border: 0;
border-radius: 4px 4px 0 0;
display: flex;
font-weight: bold;
gap: ${({ theme }) => theme.spacing(1)};
position: relative;
text-align: center;
`;
const StyledInnerCard = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.light};
background-color: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(2)} 0
${({ theme }) => theme.spacing(2)} 0;
display: flex;
flex-flow: column nowrap;
gap: ${({ theme }) => theme.spacing(0.5)};
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledCardRow = styled.div`
align-items: center;
display: flex;
height: 24px;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledCardRowOther = styled.div`
align-items: center;
display: flex;
height: 24px;
padding: 0 ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledCardRowText = styled.div``;
const StyledObjectInstanceCount = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledObjectLink = styled(Link)`
align-items: center;
display: flex;
text-decoration: none;
color: ${({ theme }) => theme.font.color.primary};
&:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
export const SettingsDataModelOverviewObject = ({
data,
}: SettingsDataModelOverviewObjectProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { totalCount } = useFindManyRecords({
objectNameSingular: data.nameSingular,
});
const fields = data.fields.filter((x) => !x.isSystem);
const countNonRelation = fields.filter(
(x) => x.type !== FieldMetadataType.Relation,
).length;
const Icon = getIcon(data.icon);
return (
<StyledNode>
<StyledHeader>
<StyledObjectName onMouseEnter={() => {}} onMouseLeave={() => {}}>
<StyledObjectLink to={'/settings/objects/' + data.namePlural}>
{Icon && <Icon size={theme.icon.size.md} />}
{capitalize(data.namePlural)}
</StyledObjectLink>
<StyledObjectInstanceCount> · {totalCount}</StyledObjectInstanceCount>
</StyledObjectName>
<SettingsDataModelObjectTypeTag
objectTypeLabel={getObjectTypeLabel(data)}
></SettingsDataModelObjectTypeTag>
</StyledHeader>
<StyledInnerCard>
{fields
.filter((x) => x.type === FieldMetadataType.Relation)
.map((field) => (
<StyledCardRow>
<ObjectFieldRow field={field}></ObjectFieldRow>
</StyledCardRow>
))}
{countNonRelation > 0 && (
<StyledCardRowOther>
<IconTag size={theme.icon.size.md} />
<StyledCardRowText>
{countNonRelation} other fields
</StyledCardRowText>
</StyledCardRowOther>
)}
</StyledInnerCard>
</StyledNode>
);
};

View File

@ -0,0 +1,22 @@
import { useTheme } from '@emotion/react';
export const SettingsDataModelOverviewRelationMarkers = () => {
const theme = useTheme();
return (
<svg style={{ position: 'absolute', top: 0, left: 0 }}>
<defs>
<marker
id="marker"
viewBox="0 0 6 6"
markerHeight="6"
markerWidth="6"
refX="3"
refY="3"
fill="none"
>
<circle cx="3" cy="3" r="3" fill={theme.color.gray} />
</marker>
</defs>
</svg>
);
};

View File

@ -0,0 +1,19 @@
import { calculateHandlePosition } from '../calculateHandlePosition';
describe('calculatePosition', () => {
test('should calculate source handle', () => {
// Source node right from start of target node
expect(calculateHandlePosition(220, 1000, 220, 540, 'source')).toBe('left');
expect(calculateHandlePosition(220, 600, 220, 540, 'source')).toBe('left');
// Source node left from start of target node
expect(calculateHandlePosition(220, 0, 220, 540, 'source')).toBe('right');
});
test('should calculate target handle', () => {
// Source node right from start of target node
expect(calculateHandlePosition(220, 1200, 220, 540, 'target')).toBe(
'right',
);
// Source node left from start of target node
expect(calculateHandlePosition(220, 0, 220, 540, 'target')).toBe('left');
});
});

View File

@ -0,0 +1,24 @@
export const calculateHandlePosition = (
sourceNodeWidth: number,
sourceNodeX: number,
targetNodeWidth: number,
targetNodeX: number,
type: 'source' | 'target',
) => {
if (type === 'source') {
if (
sourceNodeX > targetNodeX + targetNodeWidth ||
sourceNodeX + sourceNodeWidth > targetNodeX
) {
return 'left';
}
return 'right';
}
if (type === 'target') {
if (sourceNodeX > targetNodeX + targetNodeWidth) {
return 'right';
}
return 'left';
}
};

View File

@ -1,12 +1,8 @@
import { useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconX } from 'twenty-ui';
import { IconEye } from 'twenty-ui';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Button } from '@/ui/input/button/components/Button';
import { Card } from '@/ui/layout/card/components/Card';
import { AnimatedFadeOut } from '@/ui/utilities/animation/components/AnimatedFadeOut';
import { cookieStorage } from '~/utils/cookie-storage';
import DarkCoverImage from '../assets/cover-dark.png';
import LightCoverImage from '../assets/cover-light.png';
@ -24,45 +20,23 @@ const StyledCoverImageContainer = styled(Card)`
height: 153px;
justify-content: center;
position: relative;
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
const StyledTitle = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
const StyledButtonContainer = styled.div`
padding-top: ${({ theme }) => theme.spacing(5)};
`;
const StyledLighIconButton = styled(LightIconButton)`
position: absolute;
right: ${({ theme }) => theme.spacing(1)};
top: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsObjectCoverImage = () => {
const theme = useTheme();
const [cookieState, setCookieState] = useState(
cookieStorage.getItem('settings-object-cover-image'),
);
return (
<AnimatedFadeOut
isOpen={cookieState !== 'closed'}
marginBottom={theme.spacing(8)}
>
<StyledCoverImageContainer>
<StyledTitle>Build your business logic</StyledTitle>
<StyledLighIconButton
Icon={IconX}
accent="tertiary"
<StyledCoverImageContainer>
<StyledButtonContainer>
<Button
Icon={IconEye}
title="Visualize"
size="small"
onClick={() => {
cookieStorage.setItem('settings-object-cover-image', 'closed');
setCookieState('closed');
}}
/>
</StyledCoverImageContainer>
</AnimatedFadeOut>
to="/settings/objects/overview"
></Button>
</StyledButtonContainer>
</StyledCoverImageContainer>
);
};

View File

@ -9,6 +9,7 @@ export enum SettingsPath {
AccountsEmailsInboxSettings = 'accounts/emails/:accountUuid',
Billing = 'billing',
Objects = 'objects',
ObjectOverview = 'objects/overview',
ObjectDetail = 'objects/:objectSlug',
ObjectEdit = 'objects/:objectSlug/edit',
ObjectNewFieldStep1 = 'objects/:objectSlug/new-field/step-1',

View File

@ -0,0 +1,12 @@
import { IconSettings } from 'twenty-ui';
import { SettingsDataModelOverview } from '@/settings/data-model/graph-overview/components/SettingsDataModelOverview';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
export const SettingsObjectOverview = () => {
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsDataModelOverview />
</SubMenuTopBarContainer>
);
};