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:
@ -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 />}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 <></>;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user