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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': minor
---
New DependencyGraph component added to core package.
+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';
+121
View File
@@ -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"