Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[8.x] [Cloud Security] Added popover support for graph component (#199053) #199889

Merged
merged 2 commits into from
Nov 13, 2024
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 @@ -128,7 +128,7 @@ export const SvgDefsMarker = () => {
const { euiTheme } = useEuiTheme();

return (
<svg style={{ position: 'absolute', top: 0, left: 0 }}>
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
<defs>
<Marker id="primary" color={euiTheme.colors.primary} />
<Marker id="danger" color={euiTheme.colors.danger} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* 2.0.
*/

import React, { useMemo, useRef, useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { size, isEmpty, isEqual, xorWith } from 'lodash';
import {
Background,
Controls,
Expand All @@ -14,7 +15,8 @@ import {
useEdgesState,
useNodesState,
} from '@xyflow/react';
import type { Edge, Node } from '@xyflow/react';
import type { Edge, FitViewOptions, Node, ReactFlowInstance } from '@xyflow/react';
import { useGeneratedHtmlId } from '@elastic/eui';
import type { CommonProps } from '@elastic/eui';
import { SvgDefsMarker } from '../edge/styles';
import {
Expand All @@ -33,9 +35,23 @@ import type { EdgeViewModel, NodeViewModel } from '../types';
import '@xyflow/react/dist/style.css';

export interface GraphProps extends CommonProps {
/**
* Array of node view models to be rendered in the graph.
*/
nodes: NodeViewModel[];
/**
* Array of edge view models to be rendered in the graph.
*/
edges: EdgeViewModel[];
/**
* Determines whether the graph is interactive (allows panning, zooming, etc.).
* When set to false, the graph is locked and user interactions are disabled, effectively putting it in view-only mode.
*/
interactive: boolean;
/**
* Determines whether the graph is locked. Nodes and edges are still interactive, but the graph itself is not.
*/
isLocked?: boolean;
}

const nodeTypes = {
Expand Down Expand Up @@ -66,28 +82,47 @@ const edgeTypes = {
*
* @returns {JSX.Element} The rendered Graph component.
*/
export const Graph: React.FC<GraphProps> = ({ nodes, edges, interactive, ...rest }) => {
const layoutCalled = useRef(false);
const [isGraphLocked, setIsGraphLocked] = useState(interactive);
const { initialNodes, initialEdges } = useMemo(
() => processGraph(nodes, edges, isGraphLocked),
[nodes, edges, isGraphLocked]
);

const [nodesState, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edgesState, _setEdges, onEdgesChange] = useEdgesState(initialEdges);

if (!layoutCalled.current) {
const { nodes: layoutedNodes } = layoutGraph(nodesState, edgesState);
setNodes(layoutedNodes);
layoutCalled.current = true;
}
export const Graph: React.FC<GraphProps> = ({
nodes,
edges,
interactive,
isLocked = false,
...rest
}) => {
const backgroundId = useGeneratedHtmlId();
const fitViewRef = useRef<
((fitViewOptions?: FitViewOptions<Node> | undefined) => Promise<boolean>) | null
>(null);
const currNodesRef = useRef<NodeViewModel[]>([]);
const currEdgesRef = useRef<EdgeViewModel[]>([]);
const [isGraphInteractive, setIsGraphInteractive] = useState(interactive);
const [nodesState, setNodes, onNodesChange] = useNodesState<Node<NodeViewModel>>([]);
const [edgesState, setEdges, onEdgesChange] = useEdgesState<Edge<EdgeViewModel>>([]);

useEffect(() => {
// On nodes or edges changes reset the graph and re-layout
if (
!isArrayOfObjectsEqual(nodes, currNodesRef.current) ||
!isArrayOfObjectsEqual(edges, currEdgesRef.current)
) {
const { initialNodes, initialEdges } = processGraph(nodes, edges, isGraphInteractive);
const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges);

setNodes(layoutedNodes);
setEdges(initialEdges);
currNodesRef.current = nodes;
currEdgesRef.current = edges;
setTimeout(() => {
fitViewRef.current?.();
}, 30);
}
}, [nodes, edges, setNodes, setEdges, isGraphInteractive]);

const onInteractiveStateChange = useCallback(
(interactiveStatus: boolean): void => {
setIsGraphLocked(interactiveStatus);
setNodes((prevNodes) =>
prevNodes.map((node) => ({
setIsGraphInteractive(interactiveStatus);
setNodes((currNodes) =>
currNodes.map((node) => ({
...node,
data: {
...node.data,
Expand All @@ -99,40 +134,47 @@ export const Graph: React.FC<GraphProps> = ({ nodes, edges, interactive, ...rest
[setNodes]
);

const onInitCallback = useCallback(
(xyflow: ReactFlowInstance<Node<NodeViewModel>, Edge<EdgeViewModel>>) => {
window.requestAnimationFrame(() => xyflow.fitView());
fitViewRef.current = xyflow.fitView;

// When the graph is not initialized as interactive, we need to fit the view on resize
if (!interactive) {
const resizeObserver = new ResizeObserver(() => {
xyflow.fitView();
});
resizeObserver.observe(document.querySelector('.react-flow') as Element);
return () => resizeObserver.disconnect();
}
},
[interactive]
);

return (
<div {...rest}>
<SvgDefsMarker />
<ReactFlow
fitView={true}
onInit={(xyflow) => {
window.requestAnimationFrame(() => xyflow.fitView());

// When the graph is not initialized as interactive, we need to fit the view on resize
if (!interactive) {
const resizeObserver = new ResizeObserver(() => {
xyflow.fitView();
});
resizeObserver.observe(document.querySelector('.react-flow') as Element);
return () => resizeObserver.disconnect();
}
}}
onInit={onInitCallback}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodesState}
edges={edgesState}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
proOptions={{ hideAttribution: true }}
panOnDrag={isGraphLocked}
zoomOnScroll={isGraphLocked}
zoomOnPinch={isGraphLocked}
zoomOnDoubleClick={isGraphLocked}
preventScrolling={isGraphLocked}
nodesDraggable={interactive && isGraphLocked}
panOnDrag={isGraphInteractive && !isLocked}
zoomOnScroll={isGraphInteractive && !isLocked}
zoomOnPinch={isGraphInteractive && !isLocked}
zoomOnDoubleClick={isGraphInteractive && !isLocked}
preventScrolling={interactive}
nodesDraggable={interactive && isGraphInteractive && !isLocked}
maxZoom={1.3}
minZoom={0.1}
>
{interactive && <Controls onInteractiveChange={onInteractiveStateChange} />}
<Background />
<Background id={backgroundId} />{' '}
</ReactFlow>
</div>
);
Expand Down Expand Up @@ -173,32 +215,41 @@ const processGraph = (
return node;
});

const initialEdges: Array<Edge<EdgeViewModel>> = edgesModel.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';

return {
id: edgeData.id,
type: 'default',
source: edgeData.source,
sourceHandle: isInside ? 'inside' : isOutside ? 'outside' : undefined,
target: edgeData.target,
targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
focusable: false,
selectable: false,
data: {
...edgeData,
sourceShape: nodesById[edgeData.source].shape,
targetShape: nodesById[edgeData.target].shape,
},
};
});
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';

return {
id: edgeData.id,
type: 'default',
source: edgeData.source,
sourceHandle: isInside ? 'inside' : isOutside ? 'outside' : undefined,
target: edgeData.target,
targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
focusable: false,
selectable: false,
data: {
...edgeData,
sourceShape: nodesById[edgeData.source].shape,
targetShape: nodesById[edgeData.target].shape,
},
};
});

return { initialNodes, initialEdges };
};

const isArrayOfObjectsEqual = (x: object[], y: object[]) =>
size(x) === size(y) && isEmpty(xorWith(x, y, isEqual));
Loading