Merge pull request #5466 from K-Phoen/explore-org
Add "Organization" tab with a diagram
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-explore': patch
|
||||
---
|
||||
|
||||
Add "Organization" tab with a diagram
|
||||
@@ -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 & 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';
|
||||
Reference in New Issue
Block a user