Merge pull request #5466 from K-Phoen/explore-org

Add "Organization" tab with a diagram
This commit is contained in:
Ben Lambert
2021-05-07 10:51:37 +02:00
committed by GitHub
7 changed files with 417 additions and 0 deletions
@@ -16,6 +16,7 @@
import { TabbedLayout } from '@backstage/core';
import React from 'react';
import { DomainExplorerContent } from '../DomainExplorerContent';
import { GroupsExplorerContent } from '../GroupsExplorerContent';
import { ToolExplorerContent } from '../ToolExplorerContent';
export const ExploreTabs = () => (
@@ -23,6 +24,9 @@ export const ExploreTabs = () => (
<TabbedLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</TabbedLayout.Route>
<TabbedLayout.Route path="groups" title="Groups">
<GroupsExplorerContent />
</TabbedLayout.Route>
<TabbedLayout.Route path="tools" title="Tools">
<ToolExplorerContent />
</TabbedLayout.Route>
@@ -0,0 +1,69 @@
/*
* 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 { ApiProvider, ApiRegistry } from '@backstage/core';
import {
catalogApiRef,
CatalogApi,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { GroupsDiagram } from './GroupsDiagram';
describe('<GroupsDiagram />', () => {
beforeAll(() => {
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
it('shows groups', async () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'group-a',
namespace: 'my-namespace',
},
spec: {
type: 'organization',
},
},
] as Entity[],
}),
};
const { getByText } = await renderInTestApp(
<ApiProvider apis={ApiRegistry.from([[catalogApiRef, catalogApi]])}>
<GroupsDiagram />
</ApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
expect(getByText('my-namespace/group-a')).toBeInTheDocument();
});
});
@@ -0,0 +1,190 @@
/*
* Copyright 2021 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 {
RELATION_CHILD_OF,
stringifyEntityRef,
parseEntityRef,
} from '@backstage/catalog-model';
import {
catalogApiRef,
entityRouteRef,
getEntityRelations,
formatEntityRefTitle,
} from '@backstage/plugin-catalog-react';
import {
DependencyGraph,
DependencyGraphTypes,
Progress,
useApi,
ResponseErrorPanel,
Link,
useRouteRef,
configApiRef,
} from '@backstage/core';
import { makeStyles, Typography } from '@material-ui/core';
import ZoomOutMap from '@material-ui/icons/ZoomOutMap';
import React from 'react';
import { useAsync } from 'react-use';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles((theme: BackstageTheme) => ({
organizationNode: {
fill: 'coral',
stroke: theme.palette.border,
},
groupNode: {
fill: 'yellowgreen',
stroke: theme.palette.border,
},
}));
function RenderNode(props: DependencyGraphTypes.RenderNodeProps<any>) {
const classes = useStyles();
const catalogEntityRoute = useRouteRef(entityRouteRef);
if (props.node.id === 'root') {
return (
<g>
<rect
width={180}
height={80}
rx={20}
className={classes.organizationNode}
/>
<text
x={90}
y={45}
textAnchor="middle"
alignmentBaseline="baseline"
style={{ fontWeight: 'bold' }}
>
{props.node.name}
</text>
</g>
);
}
const ref = parseEntityRef(props.node.id);
return (
<g>
<rect width={180} height={80} rx={20} className={classes.groupNode} />
<Link
to={catalogEntityRoute({
kind: ref.kind,
namespace: ref.namespace,
name: ref.name,
})}
>
<text
x={90}
y={45}
textAnchor="middle"
alignmentBaseline="baseline"
style={{ fontWeight: 'bold' }}
>
{props.node.name}
</text>
</Link>
</g>
);
}
/**
* Dynamically generates a diagram of groups registered in the catalog.
*/
export function GroupsDiagram() {
const nodes = new Array<{ id: string; kind: string; name: string }>();
const edges = new Array<{ from: string; to: string; label: string }>();
const configApi = useApi(configApiRef);
const catalogApi = useApi(catalogApiRef);
const organizationName =
configApi.getOptionalString('organization.name') ?? 'Backstage';
const { loading, error, value: catalogResponse } = useAsync(() => {
return catalogApi.getEntities({
filter: {
kind: ['Group'],
},
});
}, [catalogApi]);
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
// the root of this diagram is the organization
nodes.push({
id: 'root',
kind: 'Organization',
name: organizationName,
});
for (const catalogItem of catalogResponse?.items || []) {
const currentItemId = stringifyEntityRef(catalogItem);
nodes.push({
id: stringifyEntityRef(catalogItem),
kind: catalogItem.kind,
name: formatEntityRefTitle(catalogItem, { defaultKind: 'Group' }),
});
// Edge to parent
const catalogItemRelations_childOf = getEntityRelations(
catalogItem,
RELATION_CHILD_OF,
);
// if no parent is found, link the node to the root
if (catalogItemRelations_childOf.length === 0) {
edges.push({
from: currentItemId,
to: 'root',
label: '',
});
}
catalogItemRelations_childOf.forEach(relation => {
edges.push({
from: currentItemId,
to: stringifyEntityRef(relation),
label: '',
});
});
}
return (
<>
<DependencyGraph
nodes={nodes}
edges={edges}
nodeMargin={10}
direction={DependencyGraphTypes.Direction.RIGHT_LEFT}
renderNode={RenderNode}
/>
<Typography
variant="caption"
style={{ display: 'block', textAlign: 'right' }}
>
<ZoomOutMap style={{ verticalAlign: 'bottom' }} /> Use pinch &amp; zoom
to move around the diagram.
</Typography>
</>
);
}
@@ -0,0 +1,103 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { GroupsExplorerContent } from '../GroupsExplorerContent';
describe('<GroupsExplorerContent />', () => {
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
addLocation: jest.fn(_a => new Promise(() => {})),
getEntities: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(catalogApiRef, catalogApi)}>
{children}
</ApiProvider>
);
beforeEach(() => {
jest.resetAllMocks();
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
it('renders a groups diagram', async () => {
const entities: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'group-a',
namespace: 'my-namespace',
},
spec: {
type: 'organization',
},
},
];
catalogApi.getEntities.mockResolvedValue({ items: entities });
const { getByText } = await renderInTestApp(
<Wrapper>
<GroupsExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
await waitFor(() => {
expect(getByText('my-namespace/group-a')).toBeInTheDocument();
});
});
it('renders a friendly error if it cannot collect domains', async () => {
const catalogError = new Error('Network timeout');
catalogApi.getEntities.mockRejectedValueOnce(catalogError);
const { getByText } = await renderInTestApp(
<Wrapper>
<GroupsExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
await waitFor(() =>
expect(getByText(/Warning: Network timeout/)).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,30 @@
/*
* Copyright 2021 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 { Content, ContentHeader, SupportButton } from '@backstage/core';
import React from 'react';
import { GroupsDiagram } from './GroupsDiagram';
export const GroupsExplorerContent = () => {
return (
<Content noPadding>
<ContentHeader title="Groups">
<SupportButton>Explore your groups.</SupportButton>
</ContentHeader>
<GroupsDiagram />
</Content>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 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 { GroupsExplorerContent } from './GroupsExplorerContent';