Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ export const REACHED_NODES_LIMIT = 'REACHED_NODES_LIMIT';
export const graphResponseSchema = () =>
schema.object({
nodes: schema.arrayOf(
schema.oneOf([entityNodeDataSchema, groupNodeDataSchema, labelNodeDataSchema])
schema.oneOf([
entityNodeDataSchema,
groupNodeDataSchema,
labelNodeDataSchema,
relationshipNodeDataSchema,
])
),
edges: schema.arrayOf(edgeDataSchema),
messages: schema.maybe(schema.arrayOf(schema.oneOf([schema.literal(REACHED_NODES_LIMIT)]))),
Expand All @@ -115,6 +120,7 @@ export const nodeShapeSchema = schema.oneOf([
schema.literal('diamond'),
schema.literal('label'),
schema.literal('group'),
schema.literal('relationship'),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a side note, the naming convention here is not aligned with the current design. However, I'm thinking to propose a new change to the existing node's names.

Instead of shape, we will have element type: relationship/entity/event/stack.

So I'm ok with keeping it now as it is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"relationship" sounds very business-domain specific to me, though I don't have a better suggestion.

On the other hand, I like the idea of future renaming label nodes to event, since we also have labels in each node

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with both comments - right now we are coupling shape with node type. As I see it we should have the following:

  • node.type = element type - as mentioned @kfirpeled relationship/entity/event/stack.
  • one of the node params should be the shape. so we could reuse the component to display similar types - although need to check if its possible without major changes.

this would also require us to return additional property for each node - type.
For now i would leave current behavior and do it in a separate task.

]);

export const nodeBaseDataSchema = schema.object({
Expand Down Expand Up @@ -164,6 +170,14 @@ export const labelNodeDataSchema = schema.allOf([
}),
]);

export const relationshipNodeDataSchema = schema.allOf([
nodeBaseDataSchema,
schema.object({
shape: schema.literal('relationship'),
parentId: schema.maybe(schema.string()),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we would like to stack relationships - worth to double check with design

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left a question.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decided to support stacked nodes for relationships as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

depending on the proposed design - we could add more fields such as count and documentsData as a placeholder to store entities data if we decide to add some popover menu actions later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kfirpeled as discussed in the sync - we are going to show relationship nodes in stacked groupes as well.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since then I think we reverted that decision not to stack.. anyway. Lets leave it here for now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I would leave it for now to see if all cases are covered - if not ill remove it.

}),
]);

export const edgeDataSchema = schema.object({
id: schema.string(),
source: schema.string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
graphResponseSchema,
groupNodeDataSchema,
labelNodeDataSchema,
relationshipNodeDataSchema,
nodeColorSchema,
nodeShapeSchema,
nodeDocumentDataSchema,
Expand Down Expand Up @@ -49,9 +50,15 @@ export type GroupNodeDataModel = TypeOf<typeof groupNodeDataSchema>;

export type LabelNodeDataModel = TypeOf<typeof labelNodeDataSchema>;

export type RelationshipNodeDataModel = TypeOf<typeof relationshipNodeDataSchema>;

export type EdgeDataModel = TypeOf<typeof edgeDataSchema>;

export type NodeDataModel = EntityNodeDataModel | GroupNodeDataModel | LabelNodeDataModel;
export type NodeDataModel =
| EntityNodeDataModel
| GroupNodeDataModel
| LabelNodeDataModel
| RelationshipNodeDataModel;

export type NodeDocumentDataModel = TypeOf<typeof nodeDocumentDataSchema>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import { getMarkerEnd } from './markers';
import { useEdgeColor } from './styles';
import { STACK_NODE_HORIZONTAL_PADDING } from '../constants';
import { GRAPH_EDGE_ID } from '../test_ids';
import { isConnectorShape } from '../utils';

type EdgeColor = EdgeViewModel['color'];

const dashedStyle = {
strokeDasharray: '2 2',
};

const NODES_WITHOUT_MARKER = ['label', 'group'];
const NODES_WITHOUT_MARKER = ['label', 'group', 'relationship'];

export const DefaultEdge = memo(
({
Expand All @@ -35,9 +36,9 @@ export const DefaultEdge = memo(
data,
}: EdgeProps) => {
const color: EdgeColor = data?.color || 'primary';
const entityToLabel = data?.sourceShape !== 'group' && data?.targetShape === 'label';
const labelToEntity = data?.sourceShape === 'label' && data?.targetShape !== 'group';
const isExtraAlignment = entityToLabel || labelToEntity;
const entityToConnector = data?.sourceShape !== 'group' && isConnectorShape(data?.targetShape);
const connectorToEntity = isConnectorShape(data?.sourceShape) && data?.targetShape !== 'group';
const isExtraAlignment = entityToConnector || connectorToEntity;
const sourceMargin = getShapeHandlePosition(data?.sourceShape);
const targetMargin = getShapeHandlePosition(data?.targetShape);
const markerEnd =
Expand All @@ -56,7 +57,7 @@ export const DefaultEdge = memo(
? targetX + xOffset
: targetX -
xOffset +
(isExtraAlignment ? STACK_NODE_HORIZONTAL_PADDING / 2 : 0) * (labelToEntity ? 1 : -1);
(isExtraAlignment ? STACK_NODE_HORIZONTAL_PADDING / 2 : 0) * (connectorToEntity ? 1 : -1);

const [edgePath] = getSmoothStepPath({
// sourceX and targetX are adjusted to account for the shape handle position
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function getShapeHandlePosition(shape?: NodeShape) {
case 'diamond':
return 14;
case 'label':
case 'relationship':
return 3;
case 'group':
return 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -720,4 +720,78 @@ describe('<Graph />', () => {
});
});
});

describe('interactive class', () => {
const testNodes: NodeViewModel[] = [
{
id: 'entity1',
label: 'Entity Node',
color: 'primary',
shape: 'hexagon',
},
{
id: 'label1',
label: 'Label Node',
color: 'primary',
shape: 'label',
},
];

it('should add non-interactive class to nodes when interactive is false', async () => {
const { container } = renderGraphPreview({
nodes: testNodes,
edges: [],
interactive: false,
});

await waitFor(() => {
const nodes = container.querySelectorAll('.react-flow__node');
expect(nodes.length).toBeGreaterThan(0);

nodes.forEach((node) => {
expect(node).toHaveClass('non-interactive');
});
});
});

it('should not add non-interactive class to nodes when interactive is true', async () => {
const { container } = renderGraphPreview({
nodes: testNodes,
edges: [],
interactive: true,
});

await waitFor(() => {
const nodes = container.querySelectorAll('.react-flow__node');
expect(nodes.length).toBeGreaterThan(0);

nodes.forEach((node) => {
expect(node).not.toHaveClass('non-interactive');
});
});
});

it('should add non-interactive class to relationship nodes when interactive is false', async () => {
const nodesWithRelationship: NodeViewModel[] = [
...testNodes,
{
id: 'rel1',
label: 'Owns',
shape: 'relationship',
},
];

const { container } = renderGraphPreview({
nodes: nodesWithRelationship,
edges: [],
interactive: false,
});

await waitFor(() => {
const relationshipNode = container.querySelector('[data-id="rel1"]');
expect(relationshipNode).not.toBeNull();
expect(relationshipNode).toHaveClass('non-interactive');
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ import {
DiamondNode,
LabelNode,
EdgeGroupNode,
RelationshipNode,
} from '../node';
import { layoutGraph } from './layout_graph';
import { DefaultEdge } from '../edge';
import { Minimap } from '../minimap/minimap';
import type { EdgeViewModel, NodeViewModel } from '../types';
import { isConnectorShape } from '../utils';
import { ONLY_RENDER_VISIBLE_ELEMENTS, GRID_SIZE } from '../constants';

import '@xyflow/react/dist/style.css';
Expand Down Expand Up @@ -91,6 +93,7 @@ const nodeTypes = {
diamond: DiamondNode,
label: LabelNode,
group: EdgeGroupNode,
relationship: RelationshipNode,
};

const edgeTypes = {
Expand Down Expand Up @@ -132,19 +135,42 @@ export const Graph = memo<GraphProps>(
const currNodesRef = useRef<NodeViewModel[]>([]);
const currEdgesRef = useRef<EdgeViewModel[]>([]);
const isInitialRenderRef = useRef(true);
const [isGraphInteractive, _setIsGraphInteractive] = useState(interactive);
const [isGraphInteractive, setIsGraphInteractive] = useState(interactive);
const [nodesState, setNodes, onNodesChange] = useNodesState<Node<NodeViewModel>>([]);
const [edgesState, setEdges, onEdgesChange] = useEdgesState<Edge<EdgeViewModel>>([]);
const [reactFlowKey, setReactFlowKey] = useState(0);

// Sync isGraphInteractive with interactive prop and re-process nodes when it changes
useEffect(() => {
setIsGraphInteractive(interactive);

// Re-process graph with new interactive state if nodes exist
if (currNodesRef.current.length > 0) {
const { initialNodes, initialEdges } = processGraph(
currNodesRef.current,
currEdgesRef.current,
interactive
);
const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges);

// Force ReactFlow to remount to apply new className
setReactFlowKey((prev) => prev + 1);

setTimeout(() => {
setNodes(layoutedNodes);
setEdges(initialEdges);
}, 0);
}
}, [interactive, setNodes, setEdges]);

// Filter the ids of those nodes that are origin events
const originNodeIds = useMemo(
() => nodes.filter((node) => node.isOrigin || node.isOriginAlert).map((node) => node.id),
[nodes]
);

useEffect(() => {
// On nodes or edges changes reset the graph and re-layout
// On nodes or edges changes, or interactive state changes, reset the graph and re-layout
if (
!isArrayOfObjectsEqual(nodes, currNodesRef.current) ||
!isArrayOfObjectsEqual(edges, currEdgesRef.current)
Expand All @@ -157,7 +183,6 @@ export const Graph = memo<GraphProps>(

const { initialNodes, initialEdges } = processGraph(nodes, edges, isGraphInteractive);
const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges);

// Force ReactFlow to remount by changing the key first
setReactFlowKey((prev) => prev + 1);

Expand Down Expand Up @@ -322,14 +347,18 @@ const processGraph = (
type: nodeData.shape,
data: { ...nodeData, interactive },
position: { x: 0, y: 0 }, // Default position, should be updated later
className: interactive ? undefined : 'non-interactive',
};

if (node.type === 'group' && nodeData.shape === 'group') {
node.sourcePosition = Position.Right;
node.targetPosition = Position.Left;
node.resizing = false;
node.focusable = false;
} else if (nodeData.shape === 'label' && nodeData.parentId) {
} else if (
(nodeData.shape === 'label' || nodeData.shape === 'relationship') &&
nodeData.parentId
) {
node.parentId = nodeData.parentId;
node.extent = 'parent';
node.expandParent = false;
Expand All @@ -342,18 +371,13 @@ const processGraph = (
const initialEdges: Array<Edge<EdgeViewModel>> = edgesModel
.filter((edgeData) => nodesById[edgeData.source] && nodesById[edgeData.target])
.map((edgeData) => {
const isIn =
nodesById[edgeData.source].shape !== 'label' &&
nodesById[edgeData.target].shape === 'group';
const isInside =
nodesById[edgeData.source].shape === 'group' &&
nodesById[edgeData.target].shape === 'label';
const isOut =
nodesById[edgeData.source].shape === 'label' &&
nodesById[edgeData.target].shape === 'group';
const isOutside =
nodesById[edgeData.source].shape === 'group' &&
nodesById[edgeData.target].shape !== 'label';
const sourceShape = nodesById[edgeData.source].shape;
const targetShape = nodesById[edgeData.target].shape;

const isIn = !isConnectorShape(sourceShape) && targetShape === 'group';
const isInside = sourceShape === 'group' && isConnectorShape(targetShape);
const isOut = isConnectorShape(sourceShape) && targetShape === 'group';
const isOutside = sourceShape === 'group' && !isConnectorShape(targetShape);

return {
id: edgeData.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Dagre from '@dagrejs/dagre';
import type { Node, Edge } from '@xyflow/react';
import type { EdgeViewModel, NodeViewModel, Size } from '../types';
import { getStackNodeStyle } from '../node/styles';
import { isEntityNode, isLabelNode, isStackNode, isStackedLabel } from '../utils';
import { isEntityNode, isConnectorNode, isStackNode, isStackedLabel } from '../utils';
import {
GRID_SIZE,
STACK_NODE_VERTICAL_PADDING,
Expand Down Expand Up @@ -55,7 +55,7 @@ export const layoutGraph = (
nodes.forEach((node) => {
let size = { width: NODE_WIDTH, height: node.measured?.height ?? NODE_HEIGHT };

if (isLabelNode(node.data)) {
if (isConnectorNode(node.data)) {
size = {
height: NODE_LABEL_TOTAL_HEIGHT,
width: NODE_LABEL_WIDTH,
Expand Down Expand Up @@ -100,7 +100,7 @@ export const layoutGraph = (

const layoutedNodes = nodes.map((node) => {
// For stacked nodes, we want to keep the original position relative to the parent
if (isLabelNode(node.data) && node.data.parentId) {
if (isConnectorNode(node.data) && node.data.parentId) {
return {
...node,
position: nodesById[node.data.id].position,
Expand All @@ -114,7 +114,7 @@ export const layoutGraph = (

const dagreNode = g.node(node.data.id);

if (isLabelNode(node.data)) {
if (isConnectorNode(node.data)) {
const x = snapped(Math.round(dagreNode.x - (dagreNode.width ?? 0) / 2));
const y = Math.round(dagreNode.y - NODE_LABEL_HEIGHT / 2);

Expand Down Expand Up @@ -166,7 +166,7 @@ const layoutStackedLabels = (
nodes: Array<Node<NodeViewModel>>
): { size: Size; children: Array<Node<NodeViewModel>> } => {
const children = nodes.filter(
(child) => isLabelNode(child.data) && child.parentId === groupNode.id
(child) => isConnectorNode(child.data) && child.parentId === groupNode.id
);
const stackSize = children.length;
const stackWidth = NODE_LABEL_WIDTH + STACK_NODE_HORIZONTAL_PADDING * 2;
Expand Down
Loading
Loading