(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 (
+
+
+
+ {id}
+
+
+ );
+}
diff --git a/packages/core/src/components/DependencyGraph/DependencyGraph.stories.tsx b/packages/core/src/components/DependencyGraph/DependencyGraph.stories.tsx
new file mode 100644
index 0000000000..e39ecfc5da
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/DependencyGraph.stories.tsx
@@ -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 = () => (
+
+
+
+);
+
+export const BottomToTop = () => (
+
+
+
+);
+
+export const LeftToRight = () => (
+
+
+
+);
+
+export const RightToLeft = () => (
+
+
+
+);
+
+export const WithLabels = () => {
+ const edges = exampleEdges.map(edge => ({ ...edge, label: 'label' }));
+ return (
+
+
+
+ );
+};
+
+export const CustomNodes = () => {
+ const colors = ['pink', 'coral', 'yellowgreen', 'aquamarine'];
+ const nodes = exampleNodes.map((node, index) => ({
+ ...node,
+ description: 'Description text',
+ color: colors[index],
+ }));
+ return (
+
+ (
+
+
+
+ {props.node.id}
+
+
+ {props.node.description}
+
+
+ )}
+ />
+
+ );
+};
+
+export const CustomLabels = () => {
+ const colors = ['pink', 'coral', 'aqua'];
+ const edges = exampleEdges.map((edge, index) => ({
+ ...edge,
+ label: colors[index],
+ color: colors[index],
+ }));
+ return (
+
+ (
+
+
+
+ {props.edge.label}
+
+
+ )}
+ />
+
+ );
+};
diff --git a/packages/core/src/components/DependencyGraph/DependencyGraph.test.tsx b/packages/core/src/components/DependencyGraph/DependencyGraph.test.tsx
new file mode 100644
index 0000000000..526dc6d7dd
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/DependencyGraph.test.tsx
@@ -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('', () => {
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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) => (
+
+ {props.node.id}
+
+
+ );
+ const { getByText, findByTestId, container } = render(
+ ,
+ );
+ 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) => (
+
+ {props.edge.label}
+
+
+ );
+ const { getByText, findByTestId, container } = render(
+ ,
+ );
+ const node = await findByTestId(CUSTOM_TEST_ID);
+ expect(node).toBeInTheDocument();
+ expect(container.querySelector('circle')).toBeInTheDocument();
+ expect(getByText(labeledEdge[0].label)).toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/components/DependencyGraph/DependencyGraph.tsx b/packages/core/src/components/DependencyGraph/DependencyGraph.tsx
new file mode 100644
index 0000000000..ced0bdf276
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/DependencyGraph.tsx
@@ -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 & {
+ 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(100);
+ const [containerHeight, setContainerHeight] = React.useState(100);
+
+ const graph = React.useRef>(
+ new dagre.graphlib.Graph(),
+ );
+ const [graphWidth, setGraphWidth] = React.useState(
+ graph.current.graph()?.width || 0,
+ );
+ const [graphHeight, setGraphHeight] = React.useState(
+ graph.current.graph()?.height || 0,
+ );
+ const [graphNodes, setGraphNodes] = React.useState([]);
+ const [graphEdges, setGraphEdges] = React.useState([]);
+
+ 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(node);
+ const workspace = d3Selection.select(node.getElementById(WORKSPACE_ID));
+ const zoom = d3Zoom
+ .zoom()
+ .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 (
+
+ );
+}
diff --git a/packages/core/src/components/DependencyGraph/Edge.test.tsx b/packages/core/src/components/DependencyGraph/Edge.test.tsx
new file mode 100644
index 0000000000..b651c17bf0
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/Edge.test.tsx
@@ -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) => (
+ {props.edge.label}
+));
+
+const minProps = {
+ points: [
+ { x: 10, y: 20 },
+ { x: 20, y: 20 },
+ ],
+ id,
+ setEdge,
+ renderElement,
+ edge,
+};
+
+const label = 'label';
+const edgeWithLabel = { ...edge, label };
+
+describe('', () => {
+ 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();
+ expect(container.getElementsByTagName('g')).toHaveLength(0);
+ });
+
+ it('renders the supplied label element if label is present', () => {
+ const { getByText } = render();
+ expect(getByText(label)).toBeInTheDocument();
+ });
+
+ it('passes down edge properties to the render method if label is present', () => {
+ const edgeWithRandomProp = { ...edge, label, randomProp: true };
+ render(
+ ,
+ );
+
+ expect(renderElement).toHaveBeenCalledWith({ edge: edgeWithRandomProp });
+ });
+
+ it('calls setEdge with edge ID and actual label size after rendering', () => {
+ const { getByText } = render();
+ 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 }),
+ );
+ });
+});
diff --git a/packages/core/src/components/DependencyGraph/Edge.tsx b/packages/core/src/components/DependencyGraph/Edge.tsx
new file mode 100644
index 0000000000..5e67aca63f
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/Edge.tsx
@@ -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 = {
+ id: dagre.Edge;
+ edge: GraphEdge;
+ render?: RenderLabelFunction;
+ setEdge: (id: dagre.Edge, edge: DependencyEdge) => dagre.graphlib.Graph<{}>;
+};
+
+const renderDefault = (props: RenderLabelProps) => ;
+
+const createPath = d3Shape
+ .line()
+ .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(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 && (
+
+ )}
+ {labelProps.label ? (
+
+ {render({ edge: labelProps })}
+
+ ) : null}
+ >
+ );
+}
diff --git a/packages/core/src/components/DependencyGraph/Node.test.tsx b/packages/core/src/components/DependencyGraph/Node.test.tsx
new file mode 100644
index 0000000000..9f9f7c693a
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/Node.test.tsx
@@ -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) => (
+ {props.node.id}
+));
+
+const minProps = {
+ id: node.id,
+ node,
+ setNode,
+ render: renderElement,
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+};
+
+describe('', () => {
+ 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();
+ expect(getByText(minProps.id)).toBeInTheDocument();
+ });
+
+ it('passes down node properties to the render method', () => {
+ const nodeWithRandomProp = { ...node, randomProp: true };
+ render();
+
+ expect(renderElement).toHaveBeenCalledWith({ node: nodeWithRandomProp });
+ });
+
+ it('calls setNode with node ID and actual size after rendering', () => {
+ const { getByText } = render();
+ 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 }),
+ );
+ });
+});
diff --git a/packages/core/src/components/DependencyGraph/Node.tsx b/packages/core/src/components/DependencyGraph/Node.tsx
new file mode 100644
index 0000000000..4d64e31335
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/Node.tsx
@@ -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 = {
+ node: GraphNode;
+ render?: RenderNodeFunction;
+ setNode: dagre.graphlib.Graph['setNode'];
+};
+
+const renderDefault = (props: RenderNodeProps) => ;
+
+export function Node({
+ render = renderDefault,
+ setNode,
+ node,
+}: NodeComponentProps) {
+ const { width, height, x = 0, y = 0, ...nodeProps } = node;
+ const classes = useStyles();
+ const nodeRef = React.useRef(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 (
+
+ {render({ node: nodeProps })}
+
+ );
+}
diff --git a/packages/core/src/components/DependencyGraph/constants.ts b/packages/core/src/components/DependencyGraph/constants.ts
new file mode 100644
index 0000000000..412a677f87
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/constants.ts
@@ -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';
diff --git a/packages/core/src/components/DependencyGraph/index.ts b/packages/core/src/components/DependencyGraph/index.ts
new file mode 100644
index 0000000000..9a4f0071e3
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/index.ts
@@ -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 };
diff --git a/packages/core/src/components/DependencyGraph/types.ts b/packages/core/src/components/DependencyGraph/types.ts
new file mode 100644
index 0000000000..19173db4b3
--- /dev/null
+++ b/packages/core/src/components/DependencyGraph/types.ts
@@ -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 & {
+ from: string;
+ to: string;
+ label?: string;
+};
+
+export type GraphEdge = DependencyEdge &
+ dagre.GraphEdge &
+ EdgeProperties;
+
+export type RenderLabelProps = { edge: DependencyEdge };
+
+export type RenderLabelFunction = (
+ props: RenderLabelProps,
+) => React.ReactNode;
+
+/* Nodes */
+export type DependencyNode = T & {
+ id: string;
+};
+
+export type GraphNode = dagre.Node>;
+
+export type RenderNodeProps = { node: DependencyNode };
+
+export type RenderNodeFunction = (
+ props: RenderNodeProps,
+) => 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',
+}
diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts
index 40c4ef0c11..bcc56debea 100644
--- a/packages/core/src/components/index.ts
+++ b/packages/core/src/components/index.ts
@@ -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';
diff --git a/yarn.lock b/yarn.lock
index 8ee1728b30..c265aecd6a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4982,16 +4982,38 @@
dependencies:
postcss "5 - 7"
+"@types/d3-color@*":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.0.tgz#febdfadade56e215a4c3f612fe3000d92999f5d5"
+ integrity sha512-Bs0maTeU47rdZT+n42iQ0C4gnbnJlIDJkqHFtIsDx2tPPITDeoSdIrm+00UYXzegzArYC2GsG80eHNMwz08IAw==
+
"@types/d3-force@^1.2.1":
version "1.2.1"
resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-1.2.1.tgz#c28803ea36fe29788db69efa0ad6c2dc09544e83"
integrity sha512-jqK+I36uz4kTBjyk39meed5y31Ab+tXYN/x1dn3nZEus9yOHCLc+VrcIYLc/aSQ0Y7tMPRlIhLetulME76EiiA==
+"@types/d3-interpolate@*":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.0.tgz#325029216dc722c1c68c33ccda759f1209d35823"
+ integrity sha512-Wt1v2zTlEN8dSx8hhx6MoOhWQgTkz0Ukj7owAEIOF2QtI0e219paFX9rf/SLOr/UExWb1TcUzatU8zWwFby6gg==
+ dependencies:
+ "@types/d3-color" "*"
+
"@types/d3-path@*":
version "1.0.8"
resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.8.tgz#48e6945a8ff43ee0a1ce85c8cfa2337de85c7c79"
integrity sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA==
+"@types/d3-path@^1":
+ version "1.0.9"
+ resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz#73526b150d14cd96e701597cbf346cfd1fd4a58c"
+ integrity sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==
+
+"@types/d3-selection@*", "@types/d3-selection@^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.0.tgz#59df94a8e47ed1050a337d4ffb4d4d213aa590a8"
+ integrity sha512-EF0lWZ4tg7oDFg4YQFlbOU3936e3a9UmoQ2IXlBy1+cv2c2Pv7knhKUzGlH5Hq2sF/KeDTH1amiRPey2rrLMQA==
+
"@types/d3-shape@*":
version "1.3.2"
resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.2.tgz#a41d9d6b10d02e221696b240caf0b5d0f5a588ec"
@@ -4999,6 +5021,26 @@
dependencies:
"@types/d3-path" "*"
+"@types/d3-shape@^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.0.0.tgz#61aa065726f3c2641aedc59c3603475ab11aeb2f"
+ integrity sha512-NLzD02m5PiD1KLEDjLN+MtqEcFYn4ZL9+Rqc9ZwARK1cpKZXd91zBETbe6wpBB6Ia0D0VZbpmbW3+BsGPGnCpA==
+ dependencies:
+ "@types/d3-path" "^1"
+
+"@types/d3-zoom@^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.0.tgz#ef8b87464e8ebc7c66b70f6383d1ae841e78e7fc"
+ integrity sha512-daL0PJm4yT0ISTGa7p2lHX0kvv9FO/IR1ooWbHR/7H4jpbaKiLux5FslyS/OvISPiJ5SXb4sOqYhO6fMB6hKRw==
+ dependencies:
+ "@types/d3-interpolate" "*"
+ "@types/d3-selection" "*"
+
+"@types/dagre@^0.7.44":
+ version "0.7.44"
+ resolved "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.44.tgz#8f4b796b118ca29c132da7068fbc0d0351ee5851"
+ integrity sha512-N6HD+79w77ZVAaVO7JJDW5yJ9LAxM62FpgNGO9xEde+KVYjDRyhIMzfiErXpr1g0JPon9kwlBzoBK6s4fOww9Q==
+
"@types/diff@^4.0.2":
version "4.0.2"
resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c"
@@ -9645,11 +9687,29 @@ d3-color@1:
resolved "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a"
integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==
+"d3-color@1 - 2":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e"
+ integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==
+
"d3-dispatch@1 - 2":
version "2.0.0"
resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz#8a18e16f76dd3fcaef42163c97b926aa9b55e7cf"
integrity sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==
+d3-drag@2:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz#9eaf046ce9ed1c25c88661911c1d5a4d8eb7ea6d"
+ integrity sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==
+ dependencies:
+ d3-dispatch "1 - 2"
+ d3-selection "2"
+
+"d3-ease@1 - 2":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz#fd1762bfca00dae4bacea504b1d628ff290ac563"
+ integrity sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==
+
d3-force@^2.0.1:
version "2.1.1"
resolved "https://registry.npmjs.org/d3-force/-/d3-force-2.1.1.tgz#f20ccbf1e6c9e80add1926f09b51f686a8bc0937"
@@ -9671,11 +9731,23 @@ d3-interpolate@1, d3-interpolate@^1.3.0:
dependencies:
d3-color "1"
+"d3-interpolate@1 - 2":
+ version "2.0.1"
+ resolved "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163"
+ integrity sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==
+ dependencies:
+ d3-color "1 - 2"
+
d3-path@1:
version "1.0.9"
resolved "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
+"d3-path@1 - 2":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz#55d86ac131a0548adae241eebfb56b4582dd09d8"
+ integrity sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==
+
"d3-quadtree@1 - 2":
version "2.0.0"
resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz#edbad045cef88701f6fee3aee8e93fb332d30f9d"
@@ -9693,6 +9765,11 @@ d3-scale@^2.1.0:
d3-time "1"
d3-time-format "2"
+d3-selection@2, d3-selection@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz#94a11638ea2141b7565f883780dabc7ef6a61066"
+ integrity sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==
+
d3-shape@^1.2.0:
version "1.3.7"
resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7"
@@ -9700,6 +9777,13 @@ d3-shape@^1.2.0:
dependencies:
d3-path "1"
+d3-shape@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-2.0.0.tgz#2331b62fa784a2a1daac47a7233cfd69301381fd"
+ integrity sha512-djpGlA779ua+rImicYyyjnOjeubyhql1Jyn1HK0bTyawuH76UQRWXd+pftr67H6Fa8hSwetkgb/0id3agKWykw==
+ dependencies:
+ d3-path "1 - 2"
+
d3-time-format@2:
version "2.3.0"
resolved "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850"
@@ -9717,6 +9801,28 @@ d3-time@1:
resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6"
integrity sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==
+d3-transition@2:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz#366ef70c22ef88d1e34105f507516991a291c94c"
+ integrity sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==
+ dependencies:
+ d3-color "1 - 2"
+ d3-dispatch "1 - 2"
+ d3-ease "1 - 2"
+ d3-interpolate "1 - 2"
+ d3-timer "1 - 2"
+
+d3-zoom@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz#f04d0afd05518becce879d04709c47ecd93fba54"
+ integrity sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==
+ dependencies:
+ d3-dispatch "1 - 2"
+ d3-drag "2"
+ d3-interpolate "1 - 2"
+ d3-selection "2"
+ d3-transition "2"
+
d@1, d@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a"
@@ -9725,6 +9831,14 @@ d@1, d@^1.0.1:
es5-ext "^0.10.50"
type "^1.0.1"
+dagre@^0.8.5:
+ version "0.8.5"
+ resolved "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz#ba30b0055dac12b6c1fcc247817442777d06afee"
+ integrity sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==
+ dependencies:
+ graphlib "^2.1.8"
+ lodash "^4.17.15"
+
damerau-levenshtein@^1.0.4:
version "1.0.6"
resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791"
@@ -12568,6 +12682,13 @@ graphiql@^1.0.0-alpha.10:
regenerator-runtime "^0.13.5"
theme-ui "^0.3.1"
+graphlib@^2.1.8:
+ version "2.1.8"
+ resolved "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz#5761d414737870084c92ec7b5dbcb0592c9d35da"
+ integrity sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==
+ dependencies:
+ lodash "^4.17.15"
+
graphql-config@^3.0.2:
version "3.0.3"
resolved "https://registry.npmjs.org/graphql-config/-/graphql-config-3.0.3.tgz#58907c65ed7d6e04132321450b60e57863ea9a5f"