) => void;
+ relationPairs?: RelationPairs;
+ className?: string;
+}) => {
+ const theme = useTheme();
+ const classes = useStyles();
+ const rootEntityRefs = useMemo(
+ () =>
+ (Array.isArray(rootEntityNames)
+ ? rootEntityNames
+ : [rootEntityNames]
+ ).map(e => stringifyEntityRef(e)),
+ [rootEntityNames],
+ );
+ const errorApi = useApi(errorApiRef);
+ const { loading, error, nodes, edges } = useEntityRelationNodesAndEdges({
+ rootEntityRefs,
+ maxDepth,
+ unidirectional,
+ mergeRelations,
+ kinds,
+ relations,
+ onNodeClick,
+ relationPairs,
+ });
+
+ useEffect(() => {
+ if (error) {
+ errorApi.post(error);
+ }
+ }, [errorApi, error]);
+
+ return (
+
+ {loading && }
+ {nodes && edges && (
+
+ )}
+
+ );
+};
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts
new file mode 100644
index 0000000000..a8b8c3d1b1
--- /dev/null
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 { EntityRelationsGraph } from './EntityRelationsGraph';
+export { RELATION_PAIRS } from './relations';
+export type { RelationPairs } from './relations';
+export { Direction } from './types';
+export type { EntityEdge, EntityNode } from './types';
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/relations.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/relations.ts
new file mode 100644
index 0000000000..edde7c46f1
--- /dev/null
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/relations.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 {
+ RELATION_API_CONSUMED_BY,
+ RELATION_API_PROVIDED_BY,
+ RELATION_CHILD_OF,
+ RELATION_CONSUMES_API,
+ RELATION_DEPENDENCY_OF,
+ RELATION_DEPENDS_ON,
+ RELATION_HAS_MEMBER,
+ RELATION_HAS_PART,
+ RELATION_MEMBER_OF,
+ RELATION_OWNED_BY,
+ RELATION_OWNER_OF,
+ RELATION_PARENT_OF,
+ RELATION_PART_OF,
+ RELATION_PROVIDES_API,
+} from '@backstage/catalog-model';
+
+export type RelationPairs = [string, string][];
+
+// TODO: This file only contains the pairs for the build-in relations.
+// How to implement this when custom relations are used? Right now you can pass
+// the relations everywhere.
+// Another option is to move this into @backstage/catalog-model
+
+export const RELATION_PAIRS: RelationPairs = [
+ [RELATION_OWNER_OF, RELATION_OWNED_BY],
+ [RELATION_CONSUMES_API, RELATION_API_CONSUMED_BY],
+ [RELATION_API_PROVIDED_BY, RELATION_PROVIDES_API],
+ [RELATION_HAS_PART, RELATION_PART_OF],
+ [RELATION_PARENT_OF, RELATION_CHILD_OF],
+ [RELATION_HAS_MEMBER, RELATION_MEMBER_OF],
+ [RELATION_DEPENDS_ON, RELATION_DEPENDENCY_OF],
+];
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts
new file mode 100644
index 0000000000..a7b0c6741c
--- /dev/null
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 { DependencyGraphTypes } from '@backstage/core-components';
+import { MouseEventHandler } from 'react';
+
+export type EntityEdge = DependencyGraphTypes.DependencyEdge<{
+ relations: string[];
+ // Not used, but has to be non empty to draw a label at all!
+ label: 'visible';
+}>;
+
+export type EntityNode = DependencyGraphTypes.DependencyNode<{
+ name: string;
+ kind?: string;
+ title?: string;
+ namespace: string;
+ focused?: boolean;
+ color?: 'primary' | 'secondary' | 'default';
+ onClick?: MouseEventHandler;
+}>;
+
+export type GraphEdge = DependencyGraphTypes.GraphEdge;
+
+export type GraphNode = DependencyGraphTypes.GraphNode;
+
+export enum Direction {
+ TOP_BOTTOM = 'TB',
+ BOTTOM_TOP = 'BT',
+ LEFT_RIGHT = 'LR',
+ RIGHT_LEFT = 'RL',
+}
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.ts
new file mode 100644
index 0000000000..ef5ceaf293
--- /dev/null
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.ts
@@ -0,0 +1,365 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 {
+ RELATION_HAS_PART,
+ RELATION_OWNED_BY,
+ RELATION_OWNER_OF,
+ RELATION_PART_OF,
+} from '@backstage/catalog-model';
+import { renderHook } from '@testing-library/react-hooks';
+import { pick } from 'lodash';
+import { useEntityRelationGraph } from './useEntityRelationGraph';
+import { useEntityStore as useEntityStoreMocked } from './useEntityStore';
+
+jest.mock('./useEntityStore');
+
+const useEntityStore = useEntityStoreMocked as jest.Mock;
+
+describe('useEntityRelationGraph', () => {
+ const requestEntities = jest.fn();
+
+ beforeEach(() => {
+ const entities = {
+ 'b:d/c': {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ name: 'c',
+ namespace: 'd',
+ },
+ relations: [
+ {
+ target: {
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ type: RELATION_OWNER_OF,
+ },
+ {
+ target: {
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ type: RELATION_HAS_PART,
+ },
+ ],
+ },
+ 'k:d/a1': {
+ apiVersion: 'a',
+ kind: 'k',
+ metadata: {
+ name: 'a1',
+ namespace: 'd',
+ },
+ relations: [
+ {
+ target: {
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ type: RELATION_OWNED_BY,
+ },
+ {
+ target: {
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ type: RELATION_OWNED_BY,
+ },
+ ],
+ },
+ 'b:d/c1': {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ name: 'c1',
+ namespace: 'd',
+ },
+ relations: [
+ {
+ target: {
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ type: RELATION_PART_OF,
+ },
+ {
+ target: {
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ type: RELATION_OWNER_OF,
+ },
+ {
+ target: {
+ kind: 'b',
+ name: 'c2',
+ namespace: 'd',
+ },
+ type: RELATION_HAS_PART,
+ },
+ ],
+ },
+ 'b:d/c2': {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ name: 'c2',
+ namespace: 'd',
+ },
+ relations: [
+ {
+ target: {
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ type: RELATION_PART_OF,
+ },
+ ],
+ },
+ };
+ let rootEntityRefsFilter: string[] = [];
+
+ requestEntities.mockImplementation(r => {
+ rootEntityRefsFilter = r;
+ });
+
+ useEntityStore.mockImplementation(() => ({
+ loading: false,
+ entities: pick(entities, rootEntityRefsFilter),
+ requestEntities,
+ }));
+ });
+
+ afterEach(() => jest.resetAllMocks());
+
+ test('should return no entities for empty root entity refs', async () => {
+ useEntityStore.mockReturnValue({
+ loading: false,
+ entities: {},
+ error: undefined,
+ requestEntities,
+ });
+
+ const { result } = renderHook(() =>
+ useEntityRelationGraph({ rootEntityRefs: [] }),
+ );
+ const { entities, loading, error } = result.current;
+
+ expect(error).toBeUndefined();
+ expect(loading).toBe(false);
+ expect(entities).toEqual({});
+ expect(requestEntities).toHaveBeenNthCalledWith(1, []);
+ });
+
+ test('should pass through loading state', async () => {
+ useEntityStore.mockReturnValue({
+ loading: true,
+ entities: {},
+ error: undefined,
+ requestEntities,
+ });
+
+ const { result } = renderHook(() =>
+ useEntityRelationGraph({ rootEntityRefs: [] }),
+ );
+ const { entities, loading, error } = result.current;
+
+ expect(error).toBeUndefined();
+ expect(loading).toBe(true);
+ expect(entities).toEqual({});
+ expect(requestEntities).toHaveBeenNthCalledWith(1, []);
+ });
+
+ test('should pass through error state', async () => {
+ const err = new Error('Hello World');
+ useEntityStore.mockReturnValue({
+ loading: false,
+ entities: {},
+ error: err,
+ requestEntities,
+ });
+
+ const { result } = renderHook(() =>
+ useEntityRelationGraph({ rootEntityRefs: [] }),
+ );
+ const { entities, loading, error } = result.current;
+
+ expect(error).toBe(err);
+ expect(loading).toBe(false);
+ expect(entities).toEqual({});
+ expect(requestEntities).toHaveBeenNthCalledWith(1, []);
+ });
+
+ test('should walk relation tree', async () => {
+ const { result, rerender } = renderHook(() =>
+ useEntityRelationGraph({ rootEntityRefs: ['b:d/c'] }),
+ );
+
+ // Simulate rerendering as this is triggered automatically due to the mock
+ for (let i = 0; i < 5; ++i) {
+ rerender();
+ }
+
+ const { entities, loading, error } = result.current;
+
+ expect(error).toBeUndefined();
+ expect(loading).toBe(false);
+ expect(entities).toEqual({
+ 'b:d/c': expect.anything(),
+ 'b:d/c1': expect.anything(),
+ 'b:d/c2': expect.anything(),
+ 'k:d/a1': expect.anything(),
+ });
+ expect(requestEntities).toHaveBeenNthCalledWith(1, ['b:d/c']);
+ expect(requestEntities).toHaveBeenNthCalledWith(2, [
+ 'b:d/c',
+ 'k:d/a1',
+ 'b:d/c1',
+ ]);
+ expect(requestEntities).toHaveBeenNthCalledWith(3, [
+ 'b:d/c',
+ 'k:d/a1',
+ 'b:d/c1',
+ 'b:d/c2',
+ ]);
+ });
+
+ test('should limit max depth', async () => {
+ const { result, rerender } = renderHook(() =>
+ useEntityRelationGraph({
+ rootEntityRefs: ['b:d/c'],
+ filter: { maxDepth: 1 },
+ }),
+ );
+
+ // Simulate rerendering as this is triggered automatically due to the mock
+ for (let i = 0; i < 5; ++i) {
+ rerender();
+ }
+
+ expect(result.current.entities).toEqual({
+ 'b:d/c': expect.anything(),
+ 'b:d/c1': expect.anything(),
+ 'k:d/a1': expect.anything(),
+ });
+ });
+
+ test('should update on filter change', async () => {
+ let maxDepth: number = Number.POSITIVE_INFINITY;
+ const { result, rerender } = renderHook(() =>
+ useEntityRelationGraph({
+ rootEntityRefs: ['b:d/c'],
+ filter: { maxDepth },
+ }),
+ );
+
+ // Simulate rerendering as this is triggered automatically due to the mock
+ for (let i = 0; i < 5; ++i) {
+ rerender();
+ }
+
+ expect(result.current.entities).toEqual({
+ 'b:d/c': expect.anything(),
+ 'b:d/c1': expect.anything(),
+ 'b:d/c2': expect.anything(),
+ 'k:d/a1': expect.anything(),
+ });
+
+ maxDepth = 1;
+ // Simulate rerendering as this is triggered automatically due to the mock
+ for (let i = 0; i < 5; ++i) {
+ rerender();
+ }
+
+ expect(result.current.entities).toEqual({
+ 'b:d/c': expect.anything(),
+ 'b:d/c1': expect.anything(),
+ 'k:d/a1': expect.anything(),
+ });
+ });
+
+ test('should filter by relation', async () => {
+ const { result, rerender } = renderHook(() =>
+ useEntityRelationGraph({
+ rootEntityRefs: ['b:d/c'],
+ filter: {
+ relations: [RELATION_HAS_PART, RELATION_PART_OF],
+ },
+ }),
+ );
+
+ // Simulate rerendering as this is triggered automatically due to the mock
+ for (let i = 0; i < 5; ++i) {
+ rerender();
+ }
+
+ expect(result.current.entities).toEqual({
+ 'b:d/c': expect.anything(),
+ 'b:d/c1': expect.anything(),
+ 'b:d/c2': expect.anything(),
+ });
+ });
+
+ test('should filter by kind', async () => {
+ const { result, rerender } = renderHook(() =>
+ useEntityRelationGraph({
+ rootEntityRefs: ['b:d/c'],
+ filter: {
+ relations: [RELATION_OWNED_BY, RELATION_OWNER_OF],
+ kinds: ['k'],
+ },
+ }),
+ );
+
+ // Simulate rerendering as this is triggered automatically due to the mock
+ for (let i = 0; i < 5; ++i) {
+ rerender();
+ }
+
+ expect(result.current.entities).toEqual({
+ 'b:d/c': expect.anything(),
+ 'k:d/a1': expect.anything(),
+ });
+ });
+
+ test('should support multiple roots by kind', async () => {
+ const { result, rerender } = renderHook(() =>
+ useEntityRelationGraph({
+ rootEntityRefs: ['b:d/c', 'b:d/c2'],
+ }),
+ );
+
+ // Simulate rerendering as this is triggered automatically due to the mock
+ for (let i = 0; i < 5; ++i) {
+ rerender();
+ }
+
+ expect(result.current.entities).toEqual({
+ 'b:d/c': expect.anything(),
+ 'b:d/c1': expect.anything(),
+ 'b:d/c2': expect.anything(),
+ 'k:d/a1': expect.anything(),
+ });
+ });
+});
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts
new file mode 100644
index 0000000000..f7de4b2c1a
--- /dev/null
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 { Entity, stringifyEntityRef } from '@backstage/catalog-model';
+import { useEffect } from 'react';
+import { useEntityStore } from './useEntityStore';
+
+/**
+ * Discover the graph of entities connected by relations, starting from a set of
+ * root entities. Filters are used to select which relations to includes.
+ * Returns all discovered entities once they are loaded.
+ */
+export function useEntityRelationGraph({
+ rootEntityRefs,
+ filter: { maxDepth = Number.POSITIVE_INFINITY, relations, kinds } = {},
+}: {
+ rootEntityRefs: string[];
+ filter?: {
+ maxDepth?: number;
+ relations?: string[];
+ kinds?: string[];
+ };
+}): {
+ entities?: { [key: string]: Entity };
+ loading: boolean;
+ error?: Error;
+} {
+ const { entities, loading, error, requestEntities } = useEntityStore();
+
+ useEffect(() => {
+ const expectedEntities = new Set([...rootEntityRefs]);
+ const processedEntityRefs = new Set();
+
+ let nextDepthRefQueue = [...rootEntityRefs];
+ let depth = 0;
+
+ while (
+ nextDepthRefQueue.length > 0 &&
+ (!isFinite(maxDepth) || depth < maxDepth)
+ ) {
+ const entityRefQueue = nextDepthRefQueue;
+ nextDepthRefQueue = [];
+
+ while (entityRefQueue.length > 0) {
+ const entityRef = entityRefQueue.shift()!;
+ const entity = entities[entityRef];
+
+ processedEntityRefs.add(entityRef);
+
+ if (entity && entity.relations) {
+ for (const rel of entity.relations) {
+ if (
+ (!relations || relations.includes(rel.type)) &&
+ (!kinds || kinds.includes(rel.target.kind.toLowerCase()))
+ ) {
+ const relationEntityRef = stringifyEntityRef(rel.target);
+
+ if (!processedEntityRefs.has(relationEntityRef)) {
+ nextDepthRefQueue.push(relationEntityRef);
+ expectedEntities.add(relationEntityRef);
+ }
+ }
+ }
+ }
+ }
+
+ ++depth;
+ }
+
+ requestEntities([...expectedEntities]);
+ }, [entities, rootEntityRefs, maxDepth, relations, kinds, requestEntities]);
+
+ return {
+ entities,
+ loading,
+ error,
+ };
+}
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.ts
new file mode 100644
index 0000000000..297cb44dd6
--- /dev/null
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.ts
@@ -0,0 +1,735 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 {
+ Entity,
+ RELATION_HAS_PART,
+ RELATION_OWNED_BY,
+ RELATION_OWNER_OF,
+ RELATION_PART_OF,
+ stringifyEntityRef,
+} from '@backstage/catalog-model';
+import { renderHook } from '@testing-library/react-hooks';
+import { filter, keyBy } from 'lodash';
+import { useEntityRelationGraph as useEntityRelationGraphMocked } from './useEntityRelationGraph';
+import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
+
+jest.mock('./useEntityRelationGraph');
+
+const useEntityRelationGraph = useEntityRelationGraphMocked as jest.Mock;
+
+describe('useEntityRelationNodesAndEdges', () => {
+ beforeEach(() => {
+ const entities: { [key: string]: Entity } = {
+ 'b:d/c': {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ name: 'c',
+ namespace: 'd',
+ },
+ relations: [
+ {
+ target: {
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ type: RELATION_OWNER_OF,
+ },
+ {
+ target: {
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ type: RELATION_HAS_PART,
+ },
+ ],
+ },
+ 'k:d/a1': {
+ apiVersion: 'a',
+ kind: 'k',
+ metadata: {
+ name: 'a1',
+ namespace: 'd',
+ },
+ relations: [
+ {
+ target: {
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ type: RELATION_OWNED_BY,
+ },
+ {
+ target: {
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ type: RELATION_OWNED_BY,
+ },
+ ],
+ },
+ 'b:d/c1': {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ name: 'c1',
+ namespace: 'd',
+ },
+ relations: [
+ {
+ target: {
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ type: RELATION_PART_OF,
+ },
+ {
+ target: {
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ type: RELATION_OWNER_OF,
+ },
+ {
+ target: {
+ kind: 'b',
+ name: 'c2',
+ namespace: 'd',
+ },
+ type: RELATION_HAS_PART,
+ },
+ ],
+ },
+ 'b:d/c2': {
+ apiVersion: 'a',
+ kind: 'b',
+ metadata: {
+ name: 'c2',
+ namespace: 'd',
+ },
+ relations: [
+ {
+ target: {
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ type: RELATION_PART_OF,
+ },
+ ],
+ },
+ };
+
+ useEntityRelationGraph.mockImplementation(({ filter: { kinds } }) => ({
+ loading: false,
+ entities: keyBy(
+ filter(entities, e => !kinds || kinds.includes(e.kind)),
+ stringifyEntityRef,
+ ),
+ }));
+ });
+
+ afterAll(() => {
+ jest.resetAllMocks();
+ });
+
+ test('should forward loading state', async () => {
+ useEntityRelationGraph.mockReturnValue({
+ loading: true,
+ });
+
+ const { result } = renderHook(() =>
+ useEntityRelationNodesAndEdges({
+ rootEntityRefs: ['b:d/c'],
+ }),
+ );
+
+ const { nodes, edges, loading, error } = result.current;
+
+ expect(loading).toBe(true);
+ expect(error).toBeUndefined();
+ expect(nodes).toBeUndefined();
+ expect(edges).toBeUndefined();
+ });
+
+ test('should forward error state', async () => {
+ const returnError = new Error('Test');
+ useEntityRelationGraph.mockReturnValue({
+ loading: false,
+ error: returnError,
+ });
+
+ const { result } = renderHook(() =>
+ useEntityRelationNodesAndEdges({
+ rootEntityRefs: ['b:d/c'],
+ }),
+ );
+
+ const { nodes, edges, loading, error } = result.current;
+
+ expect(loading).toBe(false);
+ expect(error).toBe(returnError);
+ expect(nodes).toBeUndefined();
+ expect(edges).toBeUndefined();
+ });
+
+ test('should generate unidirectional graph with merged relations', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useEntityRelationNodesAndEdges({
+ rootEntityRefs: ['b:d/c'],
+ unidirectional: true,
+ mergeRelations: true,
+ }),
+ );
+
+ await waitForValueToChange(
+ () => result.current.nodes && result.current.edges,
+ );
+
+ const { nodes, edges, loading, error } = result.current;
+
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(nodes).toEqual([
+ {
+ color: 'secondary',
+ focused: true,
+ id: 'b:d/c',
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'k:d/a1',
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c1',
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c2',
+ kind: 'b',
+ name: 'c2',
+ namespace: 'd',
+ },
+ ]);
+ expect(edges).toEqual([
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
+ to: 'k:d/a1',
+ },
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_HAS_PART, RELATION_PART_OF],
+ to: 'b:d/c1',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_HAS_PART, RELATION_PART_OF],
+ to: 'b:d/c2',
+ },
+ ]);
+ });
+
+ test('should generate unidirectional graph', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useEntityRelationNodesAndEdges({
+ rootEntityRefs: ['b:d/c'],
+ unidirectional: true,
+ mergeRelations: false,
+ }),
+ );
+
+ await waitForValueToChange(
+ () => result.current.nodes && result.current.edges,
+ );
+
+ const { nodes, edges, loading, error } = result.current;
+
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(nodes).toEqual([
+ {
+ color: 'secondary',
+ focused: true,
+ id: 'b:d/c',
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'k:d/a1',
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c1',
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c2',
+ kind: 'b',
+ name: 'c2',
+ namespace: 'd',
+ },
+ ]);
+ expect(edges).toEqual([
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_OWNER_OF],
+ to: 'k:d/a1',
+ },
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_HAS_PART],
+ to: 'b:d/c1',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_HAS_PART],
+ to: 'b:d/c2',
+ },
+ ]);
+ });
+
+ test('should generate bidirectional graph with merged relations', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useEntityRelationNodesAndEdges({
+ rootEntityRefs: ['b:d/c'],
+ unidirectional: false,
+ mergeRelations: true,
+ }),
+ );
+
+ await waitForValueToChange(
+ () => result.current.nodes && result.current.edges,
+ );
+
+ const { nodes, edges, loading, error } = result.current;
+
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(nodes).toEqual([
+ {
+ color: 'secondary',
+ focused: true,
+ id: 'b:d/c',
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'k:d/a1',
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c1',
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c2',
+ kind: 'b',
+ name: 'c2',
+ namespace: 'd',
+ },
+ ]);
+ expect(edges).toEqual([
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
+ to: 'k:d/a1',
+ },
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_HAS_PART, RELATION_PART_OF],
+ to: 'b:d/c1',
+ },
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_HAS_PART, RELATION_PART_OF],
+ to: 'b:d/c1',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
+ to: 'k:d/a1',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_HAS_PART, RELATION_PART_OF],
+ to: 'b:d/c2',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_HAS_PART, RELATION_PART_OF],
+ to: 'b:d/c2',
+ },
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
+ to: 'k:d/a1',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
+ to: 'k:d/a1',
+ },
+ ]);
+ });
+
+ test('should generate bidirectional graph with all relations', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useEntityRelationNodesAndEdges({
+ rootEntityRefs: ['b:d/c'],
+ unidirectional: false,
+ mergeRelations: false,
+ }),
+ );
+
+ await waitForValueToChange(
+ () => result.current.nodes && result.current.edges,
+ );
+
+ const { nodes, edges, loading, error } = result.current;
+
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(nodes).toEqual([
+ {
+ color: 'secondary',
+ focused: true,
+ id: 'b:d/c',
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'k:d/a1',
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c1',
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c2',
+ kind: 'b',
+ name: 'c2',
+ namespace: 'd',
+ },
+ ]);
+ expect(edges).toEqual([
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_OWNER_OF],
+ to: 'k:d/a1',
+ },
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_HAS_PART],
+ to: 'b:d/c1',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_PART_OF],
+ to: 'b:d/c',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_OWNER_OF],
+ to: 'k:d/a1',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_HAS_PART],
+ to: 'b:d/c2',
+ },
+ {
+ from: 'b:d/c2',
+ label: 'visible',
+ relations: [RELATION_PART_OF],
+ to: 'b:d/c1',
+ },
+ {
+ from: 'k:d/a1',
+ label: 'visible',
+ relations: [RELATION_OWNED_BY],
+ to: 'b:d/c',
+ },
+ {
+ from: 'k:d/a1',
+ label: 'visible',
+ relations: [RELATION_OWNED_BY],
+ to: 'b:d/c1',
+ },
+ ]);
+ });
+
+ test('should generate graph with multiple root nodes', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useEntityRelationNodesAndEdges({
+ rootEntityRefs: ['b:d/c', 'b:d/c2'],
+ }),
+ );
+
+ await waitForValueToChange(
+ () => result.current.nodes && result.current.edges,
+ );
+
+ const { nodes, edges, loading, error } = result.current;
+
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(nodes).toEqual([
+ {
+ color: 'secondary',
+ focused: true,
+ id: 'b:d/c',
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'k:d/a1',
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c1',
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ {
+ color: 'secondary',
+ focused: true,
+ id: 'b:d/c2',
+ kind: 'b',
+ name: 'c2',
+ namespace: 'd',
+ },
+ ]);
+ expect(edges).toEqual([
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: ['hasPart', 'partOf'],
+ to: 'b:d/c2',
+ },
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: ['hasPart', 'partOf'],
+ to: 'b:d/c1',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: ['ownerOf', 'ownedBy'],
+ to: 'k:d/a1',
+ },
+ ]);
+ });
+
+ test('should filter by relation', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useEntityRelationNodesAndEdges({
+ rootEntityRefs: ['b:d/c'],
+ relations: [RELATION_OWNER_OF],
+ }),
+ );
+
+ await waitForValueToChange(
+ () => result.current.nodes && result.current.edges,
+ );
+
+ const { nodes, edges, loading, error } = result.current;
+
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(nodes).toEqual([
+ {
+ color: 'secondary',
+ focused: true,
+ id: 'b:d/c',
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'k:d/a1',
+ kind: 'k',
+ name: 'a1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c1',
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c2',
+ kind: 'b',
+ name: 'c2',
+ namespace: 'd',
+ },
+ ]);
+ expect(edges).toEqual([
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
+ to: 'k:d/a1',
+ },
+ ]);
+ });
+
+ test('should filter by kind', async () => {
+ const { result, waitForValueToChange } = renderHook(() =>
+ useEntityRelationNodesAndEdges({
+ rootEntityRefs: ['b:d/c'],
+ kinds: ['b'],
+ }),
+ );
+
+ await waitForValueToChange(
+ () => result.current.nodes && result.current.edges,
+ );
+
+ const { nodes, edges, loading, error } = result.current;
+
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(nodes).toEqual([
+ {
+ color: 'secondary',
+ focused: true,
+ id: 'b:d/c',
+ kind: 'b',
+ name: 'c',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c1',
+ kind: 'b',
+ name: 'c1',
+ namespace: 'd',
+ },
+ {
+ color: 'primary',
+ focused: false,
+ id: 'b:d/c2',
+ kind: 'b',
+ name: 'c2',
+ namespace: 'd',
+ },
+ ]);
+ expect(edges).toEqual([
+ {
+ from: 'b:d/c',
+ label: 'visible',
+ relations: [RELATION_HAS_PART, RELATION_PART_OF],
+ to: 'b:d/c1',
+ },
+ {
+ from: 'b:d/c1',
+ label: 'visible',
+ relations: [RELATION_HAS_PART, RELATION_PART_OF],
+ to: 'b:d/c2',
+ },
+ ]);
+ });
+});
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts
new file mode 100644
index 0000000000..adda230e94
--- /dev/null
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 {
+ ENTITY_DEFAULT_NAMESPACE,
+ stringifyEntityRef,
+} from '@backstage/catalog-model';
+import { MouseEvent, useState } from 'react';
+import { useDebounce } from 'react-use';
+import { RelationPairs, RELATION_PAIRS } from './relations';
+import { EntityEdge, EntityNode } from './types';
+import { useEntityRelationGraph } from './useEntityRelationGraph';
+
+/**
+ * Generate nodes and edges to render the entity graph.
+ */
+export function useEntityRelationNodesAndEdges({
+ rootEntityRefs,
+ maxDepth = Number.POSITIVE_INFINITY,
+ unidirectional = true,
+ mergeRelations = true,
+ kinds,
+ relations,
+ onNodeClick,
+ relationPairs = RELATION_PAIRS,
+}: {
+ rootEntityRefs: string[];
+ maxDepth?: number;
+ unidirectional?: boolean;
+ mergeRelations?: boolean;
+ kinds?: string[];
+ relations?: string[];
+ onNodeClick?: (value: EntityNode, event: MouseEvent) => void;
+ relationPairs?: RelationPairs;
+}): {
+ loading: boolean;
+ nodes?: EntityNode[];
+ edges?: EntityEdge[];
+ error?: Error;
+} {
+ const [nodesAndEdges, setNodesAndEdges] = useState<{
+ nodes?: EntityNode[];
+ edges?: EntityEdge[];
+ }>({});
+ const { entities, loading, error } = useEntityRelationGraph({
+ rootEntityRefs,
+ filter: {
+ maxDepth,
+ kinds,
+ relations,
+ },
+ });
+
+ useDebounce(
+ () => {
+ if (!entities || Object.keys(entities).length === 0) {
+ setNodesAndEdges({});
+ return;
+ }
+
+ const nodes = Object.entries(entities).map(([entityRef, entity]) => {
+ const focused = rootEntityRefs.includes(entityRef);
+ const node: EntityNode = {
+ id: entityRef,
+ title: entity.metadata?.title ?? undefined,
+ kind: entity.kind,
+ name: entity.metadata.name,
+ namespace: entity.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE,
+ focused,
+ color: focused ? 'secondary' : 'primary',
+ };
+
+ if (onNodeClick) {
+ node.onClick = event => onNodeClick(node, event);
+ }
+
+ return node;
+ });
+
+ const edges: EntityEdge[] = [];
+ const visitedNodes = new Set();
+ const nodeQueue = [...rootEntityRefs];
+
+ while (nodeQueue.length > 0) {
+ const entityRef = nodeQueue.pop()!;
+ const entity = entities[entityRef];
+ visitedNodes.add(entityRef);
+
+ if (entity) {
+ entity?.relations?.forEach(rel => {
+ const targetRef = stringifyEntityRef(rel.target);
+
+ // Check if the related entity should be displayed, if not, ignore
+ // the relation too
+ if (!entities[targetRef]) {
+ return;
+ }
+
+ if (relations && !relations.includes(rel.type)) {
+ return;
+ }
+
+ if (kinds && !kinds.includes(rel.target.kind.toLowerCase())) {
+ return;
+ }
+
+ if (!unidirectional || !visitedNodes.has(targetRef)) {
+ if (mergeRelations) {
+ const pair = relationPairs.find(
+ ([l, r]) => l === rel.type || r === rel.type,
+ ) ?? [rel.type];
+ const [left] = pair;
+
+ edges.push({
+ from: left === rel.type ? entityRef : targetRef,
+ to: left === rel.type ? targetRef : entityRef,
+ relations: pair,
+ label: 'visible',
+ });
+ } else {
+ edges.push({
+ from: entityRef,
+ to: targetRef,
+ relations: [rel.type],
+ label: 'visible',
+ });
+ }
+ }
+
+ if (!visitedNodes.has(targetRef)) {
+ nodeQueue.push(targetRef);
+ visitedNodes.add(targetRef);
+ }
+ });
+ }
+ }
+
+ setNodesAndEdges({ nodes, edges });
+ },
+ 100,
+ [
+ entities,
+ rootEntityRefs,
+ kinds,
+ relations,
+ unidirectional,
+ mergeRelations,
+ onNodeClick,
+ relationPairs,
+ ],
+ );
+
+ return {
+ loading,
+ error,
+ ...nodesAndEdges,
+ };
+}
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts
new file mode 100644
index 0000000000..d0f63fdc2b
--- /dev/null
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 { Entity } from '@backstage/catalog-model';
+import { useApi } from '@backstage/core-plugin-api';
+import { CatalogApi } from '@backstage/plugin-catalog-react';
+import { act, renderHook } from '@testing-library/react-hooks';
+import { useEntityStore } from './useEntityStore';
+
+jest.mock('@backstage/core-plugin-api');
+
+describe('useEntityStore', () => {
+ let catalogApi: jest.Mocked;
+
+ beforeEach(() => {
+ catalogApi = {
+ getEntities: jest.fn(),
+ getEntityByName: jest.fn(),
+ removeEntityByUid: jest.fn(),
+ getLocationById: jest.fn(),
+ getOriginLocationByEntity: jest.fn(),
+ getLocationByEntity: jest.fn(),
+ addLocation: jest.fn(),
+ removeLocationById: jest.fn(),
+ };
+
+ (useApi as jest.Mock).mockReturnValue(catalogApi);
+ });
+
+ afterEach(() => jest.resetAllMocks());
+
+ test('request nothing', () => {
+ const { result } = renderHook(() => useEntityStore());
+ const { entities, loading, error } = result.current;
+
+ expect(error).toBeUndefined();
+ expect(loading).toBe(false);
+ expect(entities).toEqual({});
+ });
+
+ test('request a single entity', async () => {
+ const entity: Entity = {
+ apiVersion: 'v1',
+ kind: 'kind',
+ metadata: {
+ namespace: 'namespace',
+ name: 'name',
+ },
+ };
+
+ catalogApi.getEntityByName.mockResolvedValue(entity);
+
+ const { result, waitFor } = renderHook(() => useEntityStore());
+
+ act(() => {
+ result.current.requestEntities(['kind:namespace/name']);
+ });
+
+ await waitFor(() => {
+ const { entities, loading, error } = result.current;
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(entities).toEqual({
+ 'kind:namespace/name': entity,
+ });
+ });
+ });
+
+ test('handles request failures', async () => {
+ const err = new Error('Hello World');
+ catalogApi.getEntityByName.mockRejectedValue(err);
+
+ const { result, waitFor } = renderHook(() => useEntityStore());
+
+ act(() => {
+ result.current.requestEntities(['kind:namespace/name']);
+ });
+
+ await waitFor(() => {
+ const { entities, loading, error } = result.current;
+ expect(loading).toBe(false);
+ expect(error).toBe(err);
+ expect(entities).toEqual({});
+ });
+ });
+
+ test('handles loading', async () => {
+ catalogApi.getEntityByName.mockReturnValue(new Promise(() => {}));
+
+ const { result } = renderHook(() => useEntityStore());
+
+ act(() => {
+ result.current.requestEntities(['kind:namespace/name']);
+ });
+
+ const { entities, loading, error } = result.current;
+ expect(loading).toBe(true);
+ expect(error).toBeUndefined();
+ expect(entities).toEqual({});
+ });
+
+ test('request multiple entities', async () => {
+ const entity1: Entity = {
+ apiVersion: 'v1',
+ kind: 'kind',
+ metadata: {
+ namespace: 'namespace',
+ name: 'name1',
+ },
+ };
+ const entity2: Entity = {
+ apiVersion: 'v1',
+ kind: 'kind',
+ metadata: {
+ namespace: 'namespace',
+ name: 'name2',
+ },
+ };
+
+ catalogApi.getEntityByName.mockResolvedValue(entity1);
+
+ const { result, waitFor } = renderHook(() => useEntityStore());
+
+ act(() => {
+ result.current.requestEntities(['kind:namespace/name1']);
+ });
+
+ await waitFor(() => {
+ const { entities, loading, error } = result.current;
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(entities).toEqual({
+ 'kind:namespace/name1': entity1,
+ });
+ });
+
+ catalogApi.getEntityByName.mockResolvedValue(entity2);
+
+ act(() => {
+ result.current.requestEntities([
+ 'kind:namespace/name1',
+ 'kind:namespace/name2',
+ ]);
+ });
+
+ await waitFor(() => {
+ const { entities, loading, error } = result.current;
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(entities).toEqual({
+ 'kind:namespace/name1': entity1,
+ 'kind:namespace/name2': entity2,
+ });
+ });
+ });
+
+ test('request cached entity', async () => {
+ const entity1: Entity = {
+ apiVersion: 'v1',
+ kind: 'kind',
+ metadata: {
+ namespace: 'namespace',
+ name: 'name1',
+ },
+ };
+ const entity2: Entity = {
+ apiVersion: 'v1',
+ kind: 'kind',
+ metadata: {
+ namespace: 'namespace',
+ name: 'name2',
+ },
+ };
+
+ catalogApi.getEntityByName.mockResolvedValue(entity1);
+
+ const { result, waitFor } = renderHook(() => useEntityStore());
+
+ act(() => {
+ result.current.requestEntities(['kind:namespace/name1']);
+ });
+
+ await waitFor(() => {
+ const { entities, loading, error } = result.current;
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(entities).toEqual({
+ 'kind:namespace/name1': entity1,
+ });
+ });
+
+ catalogApi.getEntityByName.mockResolvedValue(entity2);
+
+ act(() => {
+ result.current.requestEntities(['kind:namespace/name2']);
+ });
+
+ await waitFor(() => {
+ const { entities, loading, error } = result.current;
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(entities).toEqual({
+ 'kind:namespace/name2': entity2,
+ });
+ });
+
+ act(() => {
+ result.current.requestEntities(['kind:namespace/name1']);
+ });
+
+ await waitFor(() => {
+ const { entities, loading, error } = result.current;
+ expect(loading).toBe(false);
+ expect(error).toBeUndefined();
+ expect(entities).toEqual({
+ 'kind:namespace/name1': entity1,
+ });
+ });
+
+ expect(catalogApi.getEntityByName).toBeCalledTimes(2);
+ });
+});
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts
new file mode 100644
index 0000000000..8637cd6627
--- /dev/null
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 { Entity, parseEntityRef } from '@backstage/catalog-model';
+import { useApi } from '@backstage/core-plugin-api';
+import { catalogApiRef } from '@backstage/plugin-catalog-react';
+import limiterFactory from 'p-limit';
+import { Dispatch, useCallback, useRef, useState } from 'react';
+import { useAsyncFn } from 'react-use';
+
+// TODO: This is a good use case for a graphql API, once it is available in the
+// future.
+
+/**
+ * Ensures that a set of requested entities is loaded.
+ */
+export function useEntityStore(): {
+ entities: { [key: string]: Entity };
+ loading: boolean;
+ error?: Error;
+ requestEntities: Dispatch;
+} {
+ const catalogClient = useApi(catalogApiRef);
+ const state = useRef({
+ requestedEntities: new Set(),
+ outstandingEntities: new Map>(),
+ cachedEntities: new Map(),
+ });
+ const [entities, setEntities] = useState<{
+ [key: string]: Entity;
+ }>({});
+
+ const updateEntities = useCallback(() => {
+ const { cachedEntities, requestedEntities } = state.current;
+ const filteredEntities: { [key: string]: Entity } = {};
+ requestedEntities.forEach(entityRef => {
+ const entity = cachedEntities.get(entityRef);
+
+ if (entity) {
+ filteredEntities[entityRef] = entity;
+ }
+ });
+ setEntities(filteredEntities);
+ }, [state, setEntities]);
+
+ const [asyncState, fetch] = useAsyncFn(async () => {
+ const limiter = limiterFactory(10);
+ const { requestedEntities, outstandingEntities, cachedEntities } =
+ state.current;
+
+ await Promise.all(
+ Array.from(requestedEntities).map(entityRef =>
+ limiter(async () => {
+ if (cachedEntities.has(entityRef)) {
+ return;
+ }
+
+ if (outstandingEntities.has(entityRef)) {
+ await outstandingEntities.get(entityRef);
+ return;
+ }
+
+ const promise = catalogClient.getEntityByName(
+ parseEntityRef(entityRef),
+ );
+
+ outstandingEntities.set(entityRef, promise);
+
+ try {
+ const entity = await promise;
+
+ if (entity) {
+ cachedEntities.set(entityRef, entity);
+ updateEntities();
+ }
+ } finally {
+ outstandingEntities.delete(entityRef);
+ }
+ }),
+ ),
+ );
+ }, [state, updateEntities]);
+ const { loading, error } = asyncState;
+
+ const requestEntities = useCallback(
+ (entityRefs: string[]) => {
+ const n = new Set(entityRefs);
+ const { requestedEntities } = state.current;
+
+ if (
+ n.size !== requestedEntities.size ||
+ Array.from(n).some(e => !requestedEntities.has(e))
+ ) {
+ state.current.requestedEntities = n;
+ fetch();
+ updateEntities();
+ }
+ },
+ [state, fetch, updateEntities],
+ );
+
+ return {
+ entities,
+ loading,
+ error,
+ requestEntities,
+ };
+}
diff --git a/plugins/catalog-graph/src/components/index.ts b/plugins/catalog-graph/src/components/index.ts
new file mode 100644
index 0000000000..ffd372d2a9
--- /dev/null
+++ b/plugins/catalog-graph/src/components/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 * from './EntityRelationsGraph';
diff --git a/plugins/catalog-graph/src/extensions.tsx b/plugins/catalog-graph/src/extensions.tsx
new file mode 100644
index 0000000000..3b933b906c
--- /dev/null
+++ b/plugins/catalog-graph/src/extensions.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 {
+ createComponentExtension,
+ createRoutableExtension,
+} from '@backstage/core-plugin-api';
+import { catalogGraphPlugin } from './plugin';
+import { catalogGraphRouteRef } from './routes';
+
+export const EntityCatalogGraphCard = catalogGraphPlugin.provide(
+ createComponentExtension({
+ component: {
+ lazy: () =>
+ import('./components/CatalogGraphCard').then(m => m.CatalogGraphCard),
+ },
+ }),
+);
+
+export const CatalogGraphPage = catalogGraphPlugin.provide(
+ createRoutableExtension({
+ component: () =>
+ import('./components/CatalogGraphPage').then(m => m.CatalogGraphPage),
+ mountPoint: catalogGraphRouteRef,
+ }),
+);
diff --git a/plugins/catalog-graph/src/index.ts b/plugins/catalog-graph/src/index.ts
new file mode 100644
index 0000000000..1a12e9ea84
--- /dev/null
+++ b/plugins/catalog-graph/src/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 * from './components';
+export { CatalogGraphPage, EntityCatalogGraphCard } from './extensions';
+export { catalogGraphPlugin } from './plugin';
+export { catalogGraphRouteRef } from './routes';
diff --git a/plugins/catalog-graph/src/plugin.test.ts b/plugins/catalog-graph/src/plugin.test.ts
new file mode 100644
index 0000000000..73c6c940f1
--- /dev/null
+++ b/plugins/catalog-graph/src/plugin.test.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 { catalogGraphPlugin } from './plugin';
+
+describe('summary', () => {
+ it('should export plugin', () => {
+ expect(catalogGraphPlugin).toBeDefined();
+ });
+});
diff --git a/plugins/catalog-graph/src/plugin.ts b/plugins/catalog-graph/src/plugin.ts
new file mode 100644
index 0000000000..d795643450
--- /dev/null
+++ b/plugins/catalog-graph/src/plugin.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 { createPlugin } from '@backstage/core-plugin-api';
+import { catalogEntityRouteRef, catalogGraphRouteRef } from './routes';
+
+export const catalogGraphPlugin = createPlugin({
+ id: '@internal/catalog-graph',
+ routes: {
+ catalogGraph: catalogGraphRouteRef,
+ },
+ externalRoutes: {
+ catalogEntity: catalogEntityRouteRef,
+ },
+});
diff --git a/plugins/catalog-graph/src/routes.ts b/plugins/catalog-graph/src/routes.ts
new file mode 100644
index 0000000000..6516bcc1f0
--- /dev/null
+++ b/plugins/catalog-graph/src/routes.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 {
+ createExternalRouteRef,
+ createRouteRef,
+} from '@backstage/core-plugin-api';
+
+export const catalogGraphRouteRef = createRouteRef({
+ path: '/catalog-graph',
+ title: 'Catalog Graph',
+});
+
+export const catalogEntityRouteRef = createExternalRouteRef({
+ id: 'catalog-entity',
+ params: ['namespace', 'kind', 'name'],
+});
diff --git a/plugins/catalog-graph/src/setupTests.ts b/plugins/catalog-graph/src/setupTests.ts
new file mode 100644
index 0000000000..7435872609
--- /dev/null
+++ b/plugins/catalog-graph/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * 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 '@testing-library/jest-dom';
diff --git a/yarn.lock b/yarn.lock
index 0d0a6d460a..0b67173ae7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4422,6 +4422,17 @@
prop-types "^15.7.2"
react-is "^16.8.0"
+"@material-ui/lab@4.0.0-alpha.47":
+ version "4.0.0-alpha.47"
+ resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.47.tgz#757a336e4c2496f700a392ff41e25a7d460a387b"
+ integrity sha512-+WC3O0M/769D3nO9Rqupusc+lob7tQMe5/DnOjAhZ0bpXlJbhZb7N84WkEk4JgQLj6ydP8e9Jhqd1lG+mGj+xw==
+ dependencies:
+ "@babel/runtime" "^7.4.4"
+ "@material-ui/utils" "^4.9.6"
+ clsx "^1.0.4"
+ prop-types "^15.7.2"
+ react-is "^16.8.0"
+
"@material-ui/lab@4.0.0-alpha.57":
version "4.0.0-alpha.57"
resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a"
@@ -4494,7 +4505,7 @@
resolved "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz#efa1c7a0b0eaa4c7c87ac0390445f0f88b0d88f2"
integrity sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==
-"@material-ui/utils@^4.11.2", "@material-ui/utils@^4.7.1":
+"@material-ui/utils@^4.11.2", "@material-ui/utils@^4.7.1", "@material-ui/utils@^4.9.6":
version "4.11.2"
resolved "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz#f1aefa7e7dff2ebcb97d31de51aecab1bb57540a"
integrity sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==