core: DependencyGraph component (#3236)

* add dependency graph files

* add stories for DependencyGraph

* add changeset

* add margin and border to storybook examples

* support dark theme in default label

* add transition to nodes, edges and labels

* move @types to devDependencies

* clean up DependencyGraph test

* don't use default exports

* change padding format

* change marginX/Y to paddingX/Y

* fix types export

* fix storybook

* move @types/dagre from devDependencies to dependencies
This commit is contained in:
Mikaela Grundin
2020-11-06 09:27:34 +01:00
committed by GitHub
parent c56e28375f
commit 199237d2fb
16 changed files with 1363 additions and 0 deletions
+8
View File
@@ -35,10 +35,15 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/dagre": "^0.7.44",
"@types/react": "^16.9",
"@types/react-sparklines": "^1.7.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
"d3-selection": "^2.0.0",
"d3-shape": "^2.0.0",
"d3-zoom": "^2.0.0",
"dagre": "^0.8.5",
"immer": "^7.0.9",
"lodash": "^4.17.15",
"material-table": "^1.69.1",
@@ -63,6 +68,9 @@
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/classnames": "^2.2.9",
"@types/d3-selection": "^2.0.0",
"@types/d3-shape": "^2.0.0",
"@types/d3-zoom": "^2.0.0",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
@@ -0,0 +1,35 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import makeStyles from '@material-ui/core/styles/makeStyles';
import { BackstageTheme } from '@backstage/theme';
import { RenderLabelProps } from './types';
const useStyles = makeStyles((theme: BackstageTheme) => ({
text: {
fill: theme.palette.textContrast,
},
}));
export function DefaultLabel({ edge: { label } }: RenderLabelProps) {
const classes = useStyles();
return (
<text className={classes.text} textAnchor="middle">
{label}
</text>
);
}
@@ -0,0 +1,79 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { BackstageTheme } from '@backstage/theme';
import { RenderNodeProps } from './types';
const useStyles = makeStyles((theme: BackstageTheme) => ({
node: {
fill: theme.palette.background.paper,
stroke: theme.palette.border,
},
text: {
fill: theme.palette.textContrast,
},
}));
export function DefaultNode({ node: { id } }: RenderNodeProps) {
const classes = useStyles();
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const idRef = React.useRef<SVGTextElement | null>(null);
React.useLayoutEffect(() => {
// set the width to the length of the ID
if (idRef.current) {
let {
height: renderedHeight,
width: renderedWidth,
} = idRef.current.getBBox();
renderedHeight = Math.round(renderedHeight);
renderedWidth = Math.round(renderedWidth);
if (renderedHeight !== height || renderedWidth !== width) {
setWidth(renderedWidth);
setHeight(renderedHeight);
}
}
}, [width, height]);
const padding = 10;
const paddedWidth = width + padding * 2;
const paddedHeight = height + padding * 2;
return (
<g>
<rect
className={classes.node}
width={paddedWidth}
height={paddedHeight}
rx={10}
/>
<text
ref={idRef}
className={classes.text}
y={paddedHeight / 2}
x={paddedWidth / 2}
textAnchor="middle"
alignmentBaseline="middle"
>
{id}
</text>
</g>
);
}
@@ -0,0 +1,178 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { DependencyGraph } from './DependencyGraph';
import { Direction, LabelPosition } from './types';
export default {
title: 'Data Display/DependencyGraph',
component: DependencyGraph,
};
const containerStyle = { width: '100%' };
const graphStyle = { border: '1px solid grey' };
const exampleNodes = [
{ id: 'source' },
{ id: 'downstream' },
{ id: 'second-downstream' },
{ id: 'third-downstream' },
];
const exampleEdges = [
{ from: 'source', to: 'downstream' },
{ from: 'downstream', to: 'second-downstream' },
{ from: 'downstream', to: 'third-downstream' },
];
export const Default = () => (
<div style={containerStyle}>
<DependencyGraph
nodes={exampleNodes}
edges={exampleEdges}
style={graphStyle}
paddingX={50}
paddingY={50}
/>
</div>
);
export const BottomToTop = () => (
<div style={containerStyle}>
<DependencyGraph
nodes={exampleNodes}
edges={exampleEdges}
direction={Direction.BOTTOM_TOP}
style={graphStyle}
paddingX={50}
paddingY={50}
/>
</div>
);
export const LeftToRight = () => (
<div style={containerStyle}>
<DependencyGraph
nodes={exampleNodes}
edges={exampleEdges}
direction={Direction.LEFT_RIGHT}
style={graphStyle}
paddingX={50}
paddingY={50}
/>
</div>
);
export const RightToLeft = () => (
<div style={containerStyle}>
<DependencyGraph
nodes={exampleNodes}
edges={exampleEdges}
direction={Direction.RIGHT_LEFT}
style={graphStyle}
paddingX={50}
paddingY={50}
/>
</div>
);
export const WithLabels = () => {
const edges = exampleEdges.map(edge => ({ ...edge, label: 'label' }));
return (
<div style={containerStyle}>
<DependencyGraph
nodes={exampleNodes}
edges={edges}
direction={Direction.LEFT_RIGHT}
style={graphStyle}
paddingX={50}
paddingY={50}
/>
</div>
);
};
export const CustomNodes = () => {
const colors = ['pink', 'coral', 'yellowgreen', 'aquamarine'];
const nodes = exampleNodes.map((node, index) => ({
...node,
description: 'Description text',
color: colors[index],
}));
return (
<div style={containerStyle}>
<DependencyGraph
nodes={nodes}
edges={exampleEdges}
style={graphStyle}
paddingX={50}
paddingY={50}
renderNode={props => (
<g>
<rect width={200} height={100} rx={20} fill={props.node.color} />
<text
x={100}
y={45}
textAnchor="middle"
alignmentBaseline="baseline"
style={{ fontWeight: 'bold' }}
>
{props.node.id}
</text>
<text
x={100}
y={55}
textAnchor="middle"
alignmentBaseline="hanging"
>
{props.node.description}
</text>
</g>
)}
/>
</div>
);
};
export const CustomLabels = () => {
const colors = ['pink', 'coral', 'aqua'];
const edges = exampleEdges.map((edge, index) => ({
...edge,
label: colors[index],
color: colors[index],
}));
return (
<div style={containerStyle}>
<DependencyGraph
nodes={exampleNodes}
edges={edges}
labelPosition={LabelPosition.CENTER}
style={graphStyle}
paddingX={50}
paddingY={50}
renderLabel={props => (
<g>
<circle r={25} fill={props.edge.color} />
<text x={0} y={0} textAnchor="middle" alignmentBaseline="middle">
{props.edge.label}
</text>
</g>
)}
/>
</div>
);
};
@@ -0,0 +1,106 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { DependencyGraph } from './DependencyGraph';
import { RenderLabelProps, RenderNodeProps } from './types';
import { EDGE_TEST_ID, LABEL_TEST_ID, NODE_TEST_ID } from './constants';
describe('<DependencyGraph />', () => {
beforeAll(() => {
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const edges = [
{ from: nodes[0].id, to: nodes[1].id },
{ from: nodes[1].id, to: nodes[2].id },
];
const CUSTOM_TEST_ID = 'custom-test-id';
it('renders each node and edge supplied', async () => {
const { getByText, queryAllByTestId, findAllByTestId } = render(
<DependencyGraph nodes={nodes} edges={edges} />,
);
const renderedNodes = await findAllByTestId(NODE_TEST_ID);
expect(renderedNodes).toHaveLength(3);
expect(getByText(nodes[0].id)).toBeInTheDocument();
expect(getByText(nodes[1].id)).toBeInTheDocument();
expect(getByText(nodes[2].id)).toBeInTheDocument();
expect(queryAllByTestId(EDGE_TEST_ID)).toHaveLength(2);
expect(queryAllByTestId(LABEL_TEST_ID)).toHaveLength(0);
});
it('renders edge labels if present', async () => {
const labeledEdges = [
{ ...edges[0], label: 'first' },
{ ...edges[1], label: 'second' },
];
const { getByText, getAllByTestId, findAllByTestId } = render(
<DependencyGraph nodes={nodes} edges={labeledEdges} />,
);
const renderedEdges = await findAllByTestId(EDGE_TEST_ID);
expect(renderedEdges).toHaveLength(2);
expect(getAllByTestId(LABEL_TEST_ID)).toHaveLength(2);
expect(getByText(labeledEdges[0].label)).toBeInTheDocument();
expect(getByText(labeledEdges[1].label)).toBeInTheDocument();
});
it('renders nodes according to renderNode prop', async () => {
const singleNode = [nodes[0]];
const renderNode = (props: RenderNodeProps) => (
<g>
<text>{props.node.id}</text>
<circle data-testid={CUSTOM_TEST_ID} r={100} />
</g>
);
const { getByText, findByTestId, container } = render(
<DependencyGraph nodes={singleNode} edges={[]} renderNode={renderNode} />,
);
const node = await findByTestId(CUSTOM_TEST_ID);
expect(node).toBeInTheDocument();
expect(container.querySelector('circle')).toBeInTheDocument();
expect(getByText(singleNode[0].id)).toBeInTheDocument();
});
it('renders labels according to renderLabel prop', async () => {
const labeledEdge = [{ ...edges[0], label: 'label' }];
const renderLabel = (props: RenderLabelProps) => (
<g>
<text>{props.edge.label}</text>
<circle data-testid={CUSTOM_TEST_ID} r={100} />
</g>
);
const { getByText, findByTestId, container } = render(
<DependencyGraph
nodes={nodes}
edges={labeledEdge}
renderLabel={renderLabel}
/>,
);
const node = await findByTestId(CUSTOM_TEST_ID);
expect(node).toBeInTheDocument();
expect(container.querySelector('circle')).toBeInTheDocument();
expect(getByText(labeledEdge[0].label)).toBeInTheDocument();
});
});
@@ -0,0 +1,324 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import * as d3Zoom from 'd3-zoom';
import * as d3Selection from 'd3-selection';
import useTheme from '@material-ui/core/styles/useTheme';
import dagre from 'dagre';
import debounce from 'lodash/debounce';
import { BackstageTheme } from '@backstage/theme';
import {
DependencyEdge,
DependencyNode,
Direction,
Alignment,
Ranker,
RenderNodeFunction,
RenderLabelFunction,
GraphEdge,
GraphNode,
LabelPosition,
} from './types';
import { Node } from './Node';
import { Edge } from './Edge';
import { ARROW_MARKER_ID } from './constants';
export type DependencyGraphProps = React.SVGProps<SVGSVGElement> & {
edges: DependencyEdge[];
nodes: DependencyNode[];
direction?: Direction;
align?: Alignment;
nodeMargin?: number;
edgeMargin?: number;
rankMargin?: number;
paddingX?: number;
paddingY?: number;
acyclicer?: 'greedy';
ranker?: Ranker;
labelPosition?: LabelPosition;
labelOffset?: number;
edgeRanks?: number;
edgeWeight?: number;
renderNode?: RenderNodeFunction;
renderLabel?: RenderLabelFunction;
defs?: SVGDefsElement | SVGDefsElement[];
};
const WORKSPACE_ID = 'workspace';
export function DependencyGraph({
edges,
nodes,
renderNode,
direction = Direction.TOP_BOTTOM,
align,
nodeMargin = 50,
edgeMargin = 10,
rankMargin = 50,
paddingX = 0,
paddingY = 0,
acyclicer,
ranker = Ranker.NETWORK_SIMPLEX,
labelPosition = LabelPosition.RIGHT,
labelOffset = 10,
edgeRanks = 1,
edgeWeight = 1,
renderLabel,
defs,
...svgProps
}: DependencyGraphProps) {
const theme: BackstageTheme = useTheme();
const [containerWidth, setContainerWidth] = React.useState<number>(100);
const [containerHeight, setContainerHeight] = React.useState<number>(100);
const graph = React.useRef<dagre.graphlib.Graph<{}>>(
new dagre.graphlib.Graph(),
);
const [graphWidth, setGraphWidth] = React.useState<number>(
graph.current.graph()?.width || 0,
);
const [graphHeight, setGraphHeight] = React.useState<number>(
graph.current.graph()?.height || 0,
);
const [graphNodes, setGraphNodes] = React.useState<string[]>([]);
const [graphEdges, setGraphEdges] = React.useState<dagre.Edge[]>([]);
const maxWidth = Math.max(graphWidth, containerWidth);
const maxHeight = Math.max(graphHeight, containerHeight);
const containerRef = React.useMemo(
() =>
debounce((node: SVGSVGElement) => {
if (!node) {
return;
}
// Set up zooming + panning
const container = d3Selection.select<SVGSVGElement, null>(node);
const workspace = d3Selection.select(node.getElementById(WORKSPACE_ID));
const zoom = d3Zoom
.zoom<SVGSVGElement, null>()
.scaleExtent([1, 10])
.on('zoom', event => {
event.transform.x = Math.min(
0,
Math.max(
event.transform.x,
maxWidth - maxWidth * event.transform.k,
),
);
event.transform.y = Math.min(
0,
Math.max(
event.transform.y,
maxHeight - maxHeight * event.transform.k,
),
);
workspace.attr('transform', event.transform);
});
container.call(zoom);
const {
width: newContainerWidth,
height: newContainerHeight,
} = node.getBoundingClientRect();
if (containerWidth !== newContainerWidth) {
setContainerWidth(newContainerWidth);
}
if (containerHeight !== newContainerHeight) {
setContainerHeight(newContainerHeight);
}
}, 100),
[containerHeight, containerWidth, maxWidth, maxHeight],
);
const setNodesAndEdges = React.useCallback(() => {
// Cleaning up lingering nodes and edges
const currentGraphNodes = graph.current.nodes();
const currentGraphEdges = graph.current.edges();
currentGraphNodes.forEach(nodeId => {
const remainingNode = nodes.some(node => node.id === nodeId);
if (!remainingNode) {
graph.current.removeNode(nodeId);
}
});
currentGraphEdges.forEach(e => {
const remainingEdge = edges.some(
edge => edge.from === e.v && edge.to === e.w,
);
if (!remainingEdge) {
graph.current.removeEdge(e.v, e.w);
}
});
// Adding/updating nodes and edges
nodes.forEach(node => {
const existingNode = graph.current
.nodes()
.find(nodeId => node.id === nodeId);
if (existingNode) {
const { width, height, x, y } = graph.current.node(existingNode);
graph.current.setNode(existingNode, { ...node, width, height, x, y });
} else {
graph.current.setNode(node.id, { ...node, width: 0, height: 0 });
}
});
edges.forEach(e => {
graph.current.setEdge(e.from, e.to, {
...e,
label: e.label,
width: 0,
height: 0,
labelpos: labelPosition,
labeloffset: labelOffset,
weight: edgeWeight,
minlen: edgeRanks,
});
});
}, [edges, nodes, labelPosition, labelOffset, edgeWeight, edgeRanks]);
const updateGraph = React.useMemo(
() =>
debounce(
() => {
dagre.layout(graph.current);
const { height, width } = graph.current.graph();
const newHeight = Math.max(0, height || 0);
const newWidth = Math.max(0, width || 0);
setGraphWidth(newWidth);
setGraphHeight(newHeight);
setGraphNodes(graph.current.nodes());
setGraphEdges(graph.current.edges());
},
250,
{ leading: true },
),
[],
);
React.useEffect(() => {
graph.current.setGraph({
rankdir: direction,
align,
nodesep: nodeMargin,
edgesep: edgeMargin,
ranksep: rankMargin,
marginx: paddingX,
marginy: paddingY,
acyclicer,
ranker,
});
setNodesAndEdges();
updateGraph();
return updateGraph.cancel;
}, [
acyclicer,
align,
direction,
edgeMargin,
paddingX,
paddingY,
nodeMargin,
rankMargin,
ranker,
setNodesAndEdges,
updateGraph,
]);
function setNode(id: string, node: DependencyNode) {
graph.current.setNode(id, node);
updateGraph();
return graph.current;
}
function setEdge(id: dagre.Edge, edge: DependencyEdge) {
graph.current.setEdge(id, edge);
updateGraph();
return graph.current;
}
return (
<svg
ref={containerRef}
{...svgProps}
width={maxWidth}
height={maxHeight}
viewBox={`0 0 ${maxWidth} ${maxHeight}`}
>
<defs>
<marker
id={ARROW_MARKER_ID}
viewBox="0 0 24 24"
markerWidth="14"
markerHeight="14"
refX="16"
refY="12"
orient="auto"
markerUnits="strokeWidth"
>
<path
fill={theme.palette.textSubtle}
d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"
/>
</marker>
{defs}
</defs>
<g id={WORKSPACE_ID}>
<svg
width={graphWidth}
height={graphHeight}
y={maxHeight / 2 - graphHeight / 2}
x={maxWidth / 2 - graphWidth / 2}
viewBox={`0 0 ${graphWidth} ${graphHeight}`}
>
{graphEdges.map(e => {
const edge = graph.current.edge(e) as GraphEdge;
if (!edge) return null;
return (
<Edge
key={`${e.v}-${e.w}`}
id={e}
setEdge={setEdge}
render={renderLabel}
edge={edge}
/>
);
})}
{graphNodes.map((id: string) => {
const node = graph.current.node(id) as GraphNode;
if (!node) return null;
return (
<Node
key={id}
setNode={setNode}
render={renderNode}
node={node}
/>
);
})}
</svg>
</g>
</svg>
);
}
@@ -0,0 +1,100 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { Edge } from './Edge';
import { RenderLabelProps } from './types';
const fromNode = 'node';
const toNode = 'other-node';
const edge = {
from: fromNode,
to: toNode,
};
const id = {
v: fromNode,
w: toNode,
};
const setEdge = jest.fn();
const renderElement = jest.fn((props: RenderLabelProps) => (
<text>{props.edge.label}</text>
));
const minProps = {
points: [
{ x: 10, y: 20 },
{ x: 20, y: 20 },
],
id,
setEdge,
renderElement,
edge,
};
const label = 'label';
const edgeWithLabel = { ...edge, label };
describe('<Edge />', () => {
beforeEach(() => {
// jsdom does not support SVG elements so we have to fall back to HTMLUnknownElement
Object.defineProperty(window.HTMLUnknownElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
afterEach(jest.clearAllMocks);
it('does not render the supplied label element if label is missing', () => {
const { container } = render(<Edge {...minProps} />);
expect(container.getElementsByTagName('g')).toHaveLength(0);
});
it('renders the supplied label element if label is present', () => {
const { getByText } = render(<Edge {...minProps} edge={edgeWithLabel} />);
expect(getByText(label)).toBeInTheDocument();
});
it('passes down edge properties to the render method if label is present', () => {
const edgeWithRandomProp = { ...edge, label, randomProp: true };
render(
<Edge {...minProps} render={renderElement} edge={edgeWithRandomProp} />,
);
expect(renderElement).toHaveBeenCalledWith({ edge: edgeWithRandomProp });
});
it('calls setEdge with edge ID and actual label size after rendering', () => {
const { getByText } = render(<Edge {...minProps} edge={edgeWithLabel} />);
expect(getByText(label)).toBeInTheDocument();
// Updates the edge in the graph
expect(setEdge).toHaveBeenCalledWith(id, {
height: 100,
width: 100,
...edgeWithLabel,
});
// Does not pass down width/height to label
expect(renderElement).not.toHaveBeenCalledWith(
expect.objectContaining({ height: 100, width: 100 }),
);
});
});
@@ -0,0 +1,122 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import * as d3Shape from 'd3-shape';
import isFinite from 'lodash/isFinite';
import makeStyles from '@material-ui/core/styles/makeStyles';
import { BackstageTheme } from '@backstage/theme';
import {
GraphEdge,
RenderLabelProps,
RenderLabelFunction,
DependencyEdge,
} from './types';
import { ARROW_MARKER_ID, EDGE_TEST_ID, LABEL_TEST_ID } from './constants';
import { DefaultLabel } from './DefaultLabel';
const useStyles = makeStyles((theme: BackstageTheme) => ({
path: {
strokeWidth: 2,
stroke: theme.palette.textSubtle,
fill: 'none',
transition: `${theme.transitions.duration.shortest}ms`,
},
label: {
transition: `${theme.transitions.duration.shortest}ms`,
},
}));
type EdgePoint = dagre.GraphEdge['points'][0];
export type EdgeComponentProps<T = any> = {
id: dagre.Edge;
edge: GraphEdge<T>;
render?: RenderLabelFunction;
setEdge: (id: dagre.Edge, edge: DependencyEdge) => dagre.graphlib.Graph<{}>;
};
const renderDefault = (props: RenderLabelProps) => <DefaultLabel {...props} />;
const createPath = d3Shape
.line<EdgePoint>()
.x(d => d.x)
.y(d => d.y)
.curve(d3Shape.curveMonotoneX);
export function Edge({
render = renderDefault,
setEdge,
id,
edge,
}: EdgeComponentProps) {
const { x = 0, y = 0, width, height, points, ...labelProps } = edge;
const classes = useStyles();
const labelRef = React.useRef<SVGGElement>(null);
React.useLayoutEffect(() => {
// set the label width to the actual rendered width to properly layout graph
if (labelRef.current) {
let {
height: renderedHeight,
width: renderedWidth,
} = labelRef.current.getBBox();
renderedHeight = Math.round(renderedHeight);
renderedWidth = Math.round(renderedWidth);
if (renderedHeight !== height || renderedWidth !== width) {
setEdge(id, {
...edge,
height: renderedHeight,
width: renderedWidth,
});
}
}
}, [edge, height, width, setEdge, id]);
let path: string = '';
if (points) {
const finitePoints = points.filter(
(point: EdgePoint) => isFinite(point.x) && isFinite(point.y),
);
path = createPath(finitePoints) || '';
}
return (
<>
{path && (
<path
data-testid={EDGE_TEST_ID}
className={classes.path}
markerEnd={`url(#${ARROW_MARKER_ID})`}
d={path}
/>
)}
{labelProps.label ? (
<g
ref={labelRef}
data-testid={LABEL_TEST_ID}
className={classes.label}
transform={`translate(${x},${y})`}
>
{render({ edge: labelProps })}
</g>
) : null}
</>
);
}
@@ -0,0 +1,79 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import dagre from 'dagre';
import { render } from '@testing-library/react';
import { Node } from './Node';
import { RenderNodeProps } from './types';
const node = { id: 'abc' };
const setNode = jest.fn(() => new dagre.graphlib.Graph());
const renderElement = jest.fn((props: RenderNodeProps) => (
<text>{props.node.id}</text>
));
const minProps = {
id: node.id,
node,
setNode,
render: renderElement,
x: 0,
y: 0,
width: 0,
height: 0,
};
describe('<Node />', () => {
beforeEach(() => {
// jsdom does not support SVG elements so we have to fall back to HTMLUnknownElement
Object.defineProperty(window.HTMLUnknownElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
afterEach(jest.clearAllMocks);
it('renders the supplied element', () => {
const { getByText } = render(<Node {...minProps} />);
expect(getByText(minProps.id)).toBeInTheDocument();
});
it('passes down node properties to the render method', () => {
const nodeWithRandomProp = { ...node, randomProp: true };
render(<Node {...minProps} node={nodeWithRandomProp} />);
expect(renderElement).toHaveBeenCalledWith({ node: nodeWithRandomProp });
});
it('calls setNode with node ID and actual size after rendering', () => {
const { getByText } = render(<Node {...minProps} />);
expect(getByText(minProps.id)).toBeInTheDocument();
// Updates the node in the graph
expect(setNode).toHaveBeenCalledWith(node.id, {
height: 100,
width: 100,
...node,
});
// Does not pass down width/height to node
expect(renderElement).not.toHaveBeenCalledWith(
expect.objectContaining({ height: 100, width: 100 }),
);
});
});
@@ -0,0 +1,76 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import makeStyles from '@material-ui/core/styles/makeStyles';
import { DefaultNode } from './DefaultNode';
import { RenderNodeFunction, RenderNodeProps, GraphNode } from './types';
import { NODE_TEST_ID } from './constants';
const useStyles = makeStyles(theme => ({
node: {
transition: `${theme.transitions.duration.shortest}ms`,
},
}));
export type NodeComponentProps<T = any> = {
node: GraphNode<T>;
render?: RenderNodeFunction;
setNode: dagre.graphlib.Graph['setNode'];
};
const renderDefault = (props: RenderNodeProps) => <DefaultNode {...props} />;
export function Node({
render = renderDefault,
setNode,
node,
}: NodeComponentProps) {
const { width, height, x = 0, y = 0, ...nodeProps } = node;
const classes = useStyles();
const nodeRef = React.useRef<SVGGElement | null>(null);
React.useLayoutEffect(() => {
// set the node width to the actual rendered width to properly layout graph
if (nodeRef.current) {
let {
height: renderedHeight,
width: renderedWidth,
} = nodeRef.current.getBBox();
renderedHeight = Math.round(renderedHeight);
renderedWidth = Math.round(renderedWidth);
if (renderedHeight !== height || renderedWidth !== width) {
setNode(node.id, {
...node,
height: renderedHeight,
width: renderedWidth,
});
}
}
}, [node, width, height, setNode]);
return (
<g
ref={nodeRef}
data-testid={NODE_TEST_ID}
className={classes.node}
transform={`translate(${x - width / 2},${y - height / 2})`}
>
{render({ node: nodeProps })}
</g>
);
}
@@ -0,0 +1,21 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const ARROW_MARKER_ID = 'arrow-marker';
export const NODE_TEST_ID = 'node';
export const EDGE_TEST_ID = 'edge';
export const LABEL_TEST_ID = 'label';
@@ -0,0 +1,20 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as DependencyGraphTypes from './types';
export { DependencyGraph } from './DependencyGraph';
export { DependencyGraphTypes };
@@ -0,0 +1,88 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import dagre from 'dagre';
type CustomType = { [customKey: string]: any };
/* Edges */
export type DependencyEdge<T = CustomType> = T & {
from: string;
to: string;
label?: string;
};
export type GraphEdge<T = CustomType> = DependencyEdge<T> &
dagre.GraphEdge &
EdgeProperties;
export type RenderLabelProps<T = CustomType> = { edge: DependencyEdge<T> };
export type RenderLabelFunction = (
props: RenderLabelProps<any>,
) => React.ReactNode;
/* Nodes */
export type DependencyNode<T = CustomType> = T & {
id: string;
};
export type GraphNode<T = CustomType> = dagre.Node<DependencyNode<T>>;
export type RenderNodeProps<T = CustomType> = { node: DependencyNode<T> };
export type RenderNodeFunction = (
props: RenderNodeProps<any>,
) => React.ReactNode;
/* Based on: https://github.com/dagrejs/dagre/wiki#configuring-the-layout */
export type EdgeProperties = {
label?: string;
width?: number;
height?: number;
labeloffset?: number;
labelpos?: LabelPosition;
minlen?: number;
weight?: number;
[customKey: string]: any;
};
export enum Direction {
TOP_BOTTOM = 'TB',
BOTTOM_TOP = 'BT',
LEFT_RIGHT = 'LR',
RIGHT_LEFT = 'RL',
}
export enum Alignment {
UP_LEFT = 'UL',
UP_RIGHT = 'UR',
DOWN_LEFT = 'DL',
DOWN_RIGHT = 'DR',
}
export enum Ranker {
NETWORK_SIMPLEX = 'network-simplex',
TIGHT_TREE = 'tight-tree',
LONGEST_PATH = 'longest-path',
}
export enum LabelPosition {
LEFT = 'l',
RIGHT = 'r',
CENTER = 'c',
}
+1
View File
@@ -18,6 +18,7 @@ export * from './AlertDisplay';
export * from './Button';
export * from './CodeSnippet';
export * from './CopyTextButton';
export * from './DependencyGraph';
export * from './DismissableBanner';
export * from './FeatureDiscovery';
export * from './HorizontalScrollGrid';