feat(catalog, app): new api 4 plugins integration

This commit is contained in:
Ivan Shmidt
2020-08-31 14:30:01 +02:00
parent e76d33e97d
commit 43ac2c0af7
23 changed files with 492 additions and 686 deletions
+1
View File
@@ -5,6 +5,7 @@
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.19",
"@backstage/core": "^0.1.1-alpha.19",
"@backstage/catalog-model": "^0.1.1-alpha.19",
"@backstage/plugin-api-docs": "^0.1.1-alpha.19",
"@backstage/plugin-catalog": "^0.1.1-alpha.19",
"@backstage/plugin-circleci": "^0.1.1-alpha.19",
+4 -3
View File
@@ -26,11 +26,11 @@ import * as plugins from './plugins';
import { apis } from './apis';
import { hot } from 'react-hot-loader/root';
import { providers } from './identityProviders';
import { CatalogPlugin } from '@backstage/plugin-catalog';
import { CatalogRouter } from '@backstage/plugin-catalog';
// import { ExplorePlugin } from '@backstage/plugin-explore';
import { Route, Routes } from 'react-router';
import { EntityPage } from './components/catalog';
import { EntityPage } from './components/catalog/EntityPage';
const app = createApp({
apis,
@@ -56,11 +56,12 @@ const AppRoutes = () => (
<Routes>
<Route
path="/catalog/*"
element={<CatalogPlugin EntityPage={EntityPage} />}
element={<CatalogRouter EntityPage={EntityPage} />}
/>
{/* <Route path="/explore" element={<ExplorePlugin />} /> */}
</Routes>
);
const App: FC<{}> = () => (
<AppProvider>
<AlertDisplay />
+4 -1
View File
@@ -74,7 +74,10 @@ import {
TravisCIApi,
travisCIApiRef,
} from '@roadiehq/backstage-plugin-travis-ci';
import { GithubPullRequestsClient, githubPullRequestsApiRef } from '@roadiehq/backstage-plugin-github-pull-requests';
import {
GithubPullRequestsClient,
githubPullRequestsApiRef,
} from '@roadiehq/backstage-plugin-github-pull-requests';
export const apis = (config: ConfigApi) => {
// eslint-disable-next-line no-console
@@ -1,82 +0,0 @@
/*
* 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 {
GitHubActionsPlugin,
GITHUB_ACTIONS_ANNOTATION,
} from '@backstage/plugin-github-actions';
import {
useEntity,
EntityPageTabs as Tabs,
EntityMetadataCard,
} from '@backstage/plugin-catalog';
import { Grid } from '@material-ui/core';
const OverviewPage = () => {
const entity = useEntity();
return (
<Grid item sm={4}>
<EntityMetadataCard entity={entity} />
</Grid>
);
};
// Just for illustration purposes, gonna live in plugin-circle-ci
const CIRCLE_CI_ANNOTATION = 'circle-ci-slug-dummy';
const DefaultEntity = () => {
const entity = useEntity();
const isCIAvailable = [
entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION],
entity!.metadata!.annotations?.[CIRCLE_CI_ANNOTATION],
].some(Boolean);
return (
<Tabs>
<Tabs.Tab title="Overview" path="/" exact>
<OverviewPage />
</Tabs.Tab>
{isCIAvailable && (
<Tabs.Tab title="CI/CD" path="/ci-cd">
{entity!.metadata!.annotations?.[GITHUB_ACTIONS_ANNOTATION] && (
<GitHubActionsPlugin entity={entity} />
)}
</Tabs.Tab>
)}
<Tabs.Tab title="Docs" path="/docs">
{/* eslint-disable-next-line no-console */}
<div>{console.log('was here /docs')}Docs tab contents</div>
</Tabs.Tab>
</Tabs>
);
};
const Service = () => <DefaultEntity />;
const Website = () => <DefaultEntity />;
const UnkownEntity = () => <div>Unknown entity!</div>;
export const ComponentEntity = () => {
const entity = useEntity();
switch (entity.spec!.type) {
case 'service':
return <Service />;
case 'website':
return <Website />;
default:
return <UnkownEntity />;
}
};
@@ -0,0 +1,89 @@
/*
* 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 { GitHubActionsPage } from '@backstage/plugin-github-actions';
import React from 'react';
import {
EntityPageLayout,
useEntity,
EntityMetadataCard,
} from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
export const OverviewPage = ({ entity }: { entity: Entity }) => (
<EntityMetadataCard entity={entity} />
);
const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewPage entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
title="CI/CD"
element={<GitHubActionsPage entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
title="Documentation"
element={<div>Much docs, such wow ({entity.metadata.name})🐶</div>}
/>
</EntityPageLayout>
);
const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewPage entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
title="CI/CD"
element={<GitHubActionsPage entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
title="Documentation"
element={<div>Much docs, such wow ({entity.metadata.name})🐶</div>}
/>
</EntityPageLayout>
);
const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="*"
title="Overview"
element={<OverviewPage entity={entity} />}
/>
</EntityPageLayout>
);
export const EntityPage = () => {
const { entity } = useEntity();
switch (entity?.spec?.type) {
case 'service':
return <ServiceEntityPage entity={entity} />;
case 'website':
return <WebsiteEntityPage entity={entity} />;
default:
return <DefaultEntityPage entity={entity} />;
}
};
+35 -129
View File
@@ -13,152 +13,58 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useState } from 'react';
import { CatalogPage } from './components/CatalogPage/CatalogPage';
// import { EntityPage } from './components/EntityPage/EntityPage';
import { Route, Routes, useParams, useNavigate } from 'react-router';
import React, { ComponentType } from 'react';
import { CatalogPage } from './components/CatalogPage';
import { EntityPageLayout } from './components/EntityPageLayout';
import { Route, Routes } from 'react-router';
import { entityRoute, rootRoute, entityRouteDefault } from './routes';
import { useEntityFromUrl, EntityContext, useEntity } from './hooks/useEntity';
import {
pageTheme,
PageTheme,
Page,
Header,
HeaderLabel,
Content,
Progress,
} from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { FavouriteEntity } from './components/FavouriteEntity/FavouriteEntity';
import { Box } from '@material-ui/core';
import { EntityContextMenu } from './components/EntityContextMenu/EntityContextMenu';
import { UnregisterEntityDialog } from './components/UnregisterEntityDialog/UnregisterEntityDialog';
import { Alert } from '@material-ui/lab';
// const EntityContext = React.createContext(null);
export const getPageTheme = (entity?: Entity): PageTheme => {
const themeKey = entity?.spec?.type?.toString() ?? 'home';
return pageTheme[themeKey] ?? pageTheme.home;
};
import { Content } from '@backstage/core';
import { Typography, Link } from '@material-ui/core';
import { EntityProvider } from './components/EntityProvider';
const EntityPageTitle = ({
entity,
title,
}: {
title: string;
entity: Entity | undefined;
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
const DefaultEntityPage = () => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={
<Content>
<Typography variant="h2">This is default entity page. </Typography>
<Typography variant="body1">
To override this component with your custom implementation, read
docs on{' '}
<Link target="_blank" href="https://backstage.io/docs">
backstage.io/docs
</Link>
</Typography>
</Content>
}
/>
</EntityPageLayout>
);
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
const EntityPageLayout = ({ children }: { children: React.ReactNode }) => {
const { entity, loading, error } = useEntityFromUrl();
const { optionalNamespaceAndName, kind } = useParams() as {
optionalNamespaceAndName: string;
kind: string;
};
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity!,
);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const navigate = useNavigate();
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
return (
<Page theme={getPageTheme(entity!)}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity!} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
label="Owner"
value={entity.spec?.owner || 'unknown'}
/>
<HeaderLabel
label="Lifecycle"
value={entity.spec?.lifecycle || 'unknown'}
/>
<EntityContextMenu onUnregisterEntity={showRemovalDialog} />
</>
)}
</Header>
{loading && <Progress />}
{entity && (
<EntityContext.Provider value={entity}>
{children}
</EntityContext.Provider>
)}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity!}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</Page>
);
};
export const CatalogPlugin = ({ EntityPage }) => (
export const CatalogRouter = ({
EntityPage = DefaultEntityPage,
}: {
EntityPage?: ComponentType;
}) => (
<Routes>
<Route path={`/${rootRoute.path}`} element={<CatalogPage />} />
<Route
path={`/${entityRoute.path}`}
element={
<EntityPageLayout>
<EntityProvider>
<EntityPage />
</EntityPageLayout>
</EntityProvider>
}
/>
<Route
path={`/${entityRouteDefault.path}`}
element={
<EntityPageLayout>
<EntityProvider>
<EntityPage />
</EntityPageLayout>
</EntityProvider>
}
/>
</Routes>
);
export { EntityMetadataCard } from './components/EntityMetadataCard/EntityMetadataCard';
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityPageTabs } from './EntityPageTabs';
export { CatalogPage } from './CatalogPage';
@@ -1,100 +0,0 @@
/*
* 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.
*/
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mockNavigate = jest.fn();
return {
...actual,
useNavigate: jest.fn(() => mockNavigate),
useParams: jest.fn(),
};
});
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage, getPageTheme } from './EntityPage';
const {
useParams,
useNavigate,
}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock(
'react-router-dom',
);
const errorApi = { post: () => {} };
describe('EntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
useParams.mockReturnValue({
kind: 'Component',
optionalNamespaceAndName: '',
});
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, errorApi],
[
catalogApiRef,
({
async getEntityByName() {},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
<EntityPage />
</ApiProvider>,
),
);
await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
});
});
describe('getPageTheme', () => {
const defaultPageTheme = getPageTheme();
it.each(['service', 'app', 'library', 'tool', 'documentation', 'website'])(
'should select right theme for predefined type: %p ̰ ',
type => {
const theme = getPageTheme(({
spec: {
type,
},
} as any) as Entity);
expect(theme).toBeDefined();
expect(theme).not.toBe(defaultPageTheme);
},
);
it('should select default theme for unknown/unspecified types', () => {
const theme1 = getPageTheme(({
spec: {
type: 'unknown-type',
},
} as any) as Entity);
const theme2 = getPageTheme(({
spec: {},
} as any) as Entity);
expect(theme1).toBe(defaultPageTheme);
expect(theme2).toBe(defaultPageTheme);
});
});
@@ -1,231 +0,0 @@
/*
* 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 {
Content,
errorApiRef,
Header,
HeaderLabel,
Page,
pageTheme,
PageTheme,
Progress,
useApi,
HeaderTabs,
} from '@backstage/core';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityPageDocs } from '../EntityPageDocs/EntityDocsPage';
import { EntityPageApi } from '../EntityPageApi/EntityPageApi';
import { EntityPageOverview } from '../EntityPageOverview/EntityPageOverview';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
const REDIRECT_DELAY = 1000;
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
export const getPageTheme = (entity?: Entity): PageTheme => {
const themeKey = entity?.spec?.type?.toString() ?? 'home';
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
entity,
title,
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
);
export const EntityPage: FC<{}> = () => {
const {
optionalNamespaceAndName,
kind,
selectedTabId = 'overview',
} = useParams() as {
optionalNamespaceAndName: string;
kind: string;
selectedTabId: string;
};
const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind, namespace, name }),
[catalogApi, kind, namespace, name],
);
useEffect(() => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
}, REDIRECT_DELAY);
}
}, [errorApi, navigate, error, loading, entity]);
if (!name) {
navigate('/catalog');
return null;
}
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
// TODO - Replace with proper tabs implementation
const tabs = [
{
id: 'overview',
label: 'Overview',
content: (e: Entity) => <EntityPageOverview entity={e} />,
},
{
id: 'ci',
label: 'CI/CD',
},
{
id: 'tests',
label: 'Tests',
},
{
id: 'api',
label: 'API',
show: (e: Entity) => !!e?.spec?.implementsApis,
content: (e: Entity) => <EntityPageApi entity={e} />,
},
{
id: 'monitoring',
label: 'Monitoring',
},
{
id: 'quality',
label: 'Quality',
},
{
id: 'docs',
label: 'Docs',
show: (e: Entity) =>
!!e.metadata.annotations?.['backstage.io/techdocs-ref'],
content: (e: Entity) => <EntityPageDocs entity={e} />,
},
];
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity,
);
const selectedTab = tabs.find(tab => tab.id === selectedTabId);
const filteredHeaderTabs = entity
? tabs.filter(tab => (tab.show ? tab.show(entity) : true))
: [];
return (
<Page theme={getPageTheme(entity)}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
label="Owner"
value={entity.spec?.owner || 'unknown'}
/>
<HeaderLabel
label="Lifecycle"
value={entity.spec?.lifecycle || 'unknown'}
/>
<EntityContextMenu onUnregisterEntity={showRemovalDialog} />
</>
)}
</Header>
{loading && <Progress />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{entity && (
<>
<HeaderTabs
tabs={filteredHeaderTabs}
onChange={idx => {
navigate(
`/catalog/${kind}/${optionalNamespaceAndName}/${filteredHeaderTabs[idx].id}`,
);
}}
selectedIndex={filteredHeaderTabs.findIndex(
tab => tab.id === selectedTabId,
)}
/>
{selectedTab && selectedTab.content
? selectedTab.content(entity)
: null}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</>
)}
</Page>
);
};
@@ -0,0 +1,147 @@
/*
* 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, { useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
useEntityFromUrl,
EntityContext,
useEntity,
} from '../../hooks/useEntity';
import {
pageTheme,
PageTheme,
Page,
Header,
HeaderLabel,
Content,
Progress,
} from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { Box } from '@material-ui/core';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { Alert } from '@material-ui/lab';
import { Tabbed } from './Tabbed';
const getPageTheme = (entity?: Entity): PageTheme => {
const themeKey = entity?.spec?.type?.toString() ?? 'home';
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle = ({
entity,
title,
}: {
title: string;
entity: Entity | undefined;
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
);
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
export const EntityPageLayout = ({
children,
}: {
children: React.ReactNode;
}) => {
const { optionalNamespaceAndName, kind } = useParams() as {
optionalNamespaceAndName: string;
kind: string;
};
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const { entity, loading, error } = useEntity();
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity!,
);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const navigate = useNavigate();
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
return (
<Page theme={getPageTheme(entity!)}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity!} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
label="Owner"
value={entity.spec?.owner || 'unknown'}
/>
<HeaderLabel
label="Lifecycle"
value={entity.spec?.lifecycle || 'unknown'}
/>
<EntityContextMenu onUnregisterEntity={showRemovalDialog} />
</>
)}
</Header>
{loading && <Progress />}
{entity && <Tabbed.Layout>{children}</Tabbed.Layout>}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity!}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</Page>
);
};
EntityPageLayout.Content = Tabbed.Content;
@@ -0,0 +1,98 @@
/*
* 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 {
useParams,
useNavigate,
PartialRouteObject,
matchRoutes,
RouteObject,
useRoutes,
Navigate,
RouteMatch,
} from 'react-router';
import { Tab, HeaderTabs, Content } from '@backstage/core';
import { Grid } from '@material-ui/core';
import { Helmet } from 'react-helmet';
const getSelectedIndex = (matchedRoute: RouteMatch, tabs: Tab[]) => {
if (!matchedRoute) return 0;
const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path);
return ~tabIndex ? tabIndex : 0;
};
export const Tabbed = {
Layout: ({ children }: { children: React.ReactNode }) => {
const routes: PartialRouteObject[] = [];
const tabs: Tab[] = [];
const params = useParams();
const navigate = useNavigate();
React.Children.forEach(children, child => {
if (!React.isValidElement(child)) {
// Skip conditionals resolved to falses/nulls/undefineds etc
return;
}
const pathAndId = (child as JSX.Element).props.path;
// Child here must be then always a functional component without any wrappers
tabs.push({
id: pathAndId,
label: (child as JSX.Element).props.title,
});
routes.push({
path: pathAndId,
element: child.props.element,
});
});
// Add catch-all for incorrect sub-routes
routes.push({
path: '/*',
element: <Navigate to="." />,
});
const [matchedRoute] =
matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? [];
const selectedIndex = getSelectedIndex(matchedRoute, tabs);
const currentTab = tabs[selectedIndex];
const title = currentTab.label;
const onTabChange = (index: number) =>
navigate(tabs[index].id.slice(1, -2));
const currentRouteElement = useRoutes(routes);
return (
<>
<HeaderTabs
tabs={tabs}
selectedIndex={selectedIndex}
onChange={onTabChange}
/>
<Content>
<Grid container spacing={3}>
<Helmet title={title} />
{currentRouteElement}
</Grid>
</Content>
</>
);
},
Content: (_props: { path: string; title: string; element: JSX.Element }) =>
null,
};
@@ -0,0 +1,16 @@
/*
* 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 { Tabbed } from './Tabbed';
@@ -0,0 +1,16 @@
/*
* 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 { EntityPageLayout } from './EntityPageLayout';
@@ -1,98 +0,0 @@
/*
* 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 {
useParams,
useNavigate,
PartialRouteObject,
matchRoutes,
RouteObject,
useRoutes,
Navigate,
RouteMatch,
} from 'react-router';
import { Tab, HeaderTabs, Content } from '@backstage/core';
import { Grid } from '@material-ui/core';
import { Helmet } from 'react-helmet';
const getSelectedIndex = (matchedRoute: RouteMatch, tabs: Tab[]) => {
if (!matchedRoute) return 0;
const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path);
return ~tabIndex ? tabIndex : 0;
};
export const EntityPageTabs = ({ children }: { children: React.ReactNode }) => {
const routes: PartialRouteObject[] = [];
const tabs: Tab[] = [];
const params = useParams();
const navigate = useNavigate();
React.Children.forEach(children, child => {
if (!React.isValidElement(child)) {
// Skip conditionals resolved to falses/nulls/undefineds etc
return;
}
const pathAndId =
(child as JSX.Element).props.path +
((child as JSX.Element).props.exact ? '' : '/*');
routes.push({
path: pathAndId,
element: (child as JSX.Element).props.children,
});
tabs.push({
id: pathAndId,
label: (child as JSX.Element).props.title,
});
});
// Add catch-all for incorrect sub-routes
routes.push({
path: '/*',
element: <Navigate to="." />,
});
const [matchedRoute] =
matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? [];
const selectedIndex = getSelectedIndex(matchedRoute, tabs);
const currentTab = tabs[selectedIndex];
const title = currentTab.label;
const onTabChange = (index: number) => navigate(tabs[index].id.slice(1, -2));
const currentRouteElement = useRoutes(routes);
return (
<>
<HeaderTabs
tabs={tabs}
selectedIndex={selectedIndex}
onChange={onTabChange}
/>
<Content>
<Grid container spacing={3}>
<Helmet title={title} />
{currentRouteElement}
</Grid>
</Content>
</>
);
};
type TabProps = {
children: React.ReactNode;
title: string;
path: string;
exact?: boolean;
};
EntityPageTabs.Tab = (_props: TabProps) => null;
@@ -13,18 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog';
import { ComponentEntity } from './Component';
import React, { ReactNode } from 'react';
import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity';
const UnkownEntityKind = () => <div>Unknown entity kind!</div>;
export const EntityProvider = ({ children }: { children: ReactNode }) => {
const { entity, loading, error } = useEntityFromUrl();
export const EntityPage = () => {
const entity = useEntity();
switch (entity.kind) {
case 'Component':
return <ComponentEntity />;
default:
return <UnkownEntityKind />;
}
return (
<EntityContext.Provider value={{ entity, loading, error }}>
{children}
</EntityContext.Provider>
);
};
@@ -0,0 +1,16 @@
/*
* 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 { EntityProvider } from './EntityProvider';
+27 -5
View File
@@ -22,8 +22,19 @@ import { Entity } from '@backstage/catalog-model';
const REDIRECT_DELAY = 2000;
export const EntityContext = createContext<Entity>((null as any) as Entity);
export const useEntityFromUrl = () => {
type EntityLoadingStatus = {
entity?: Entity;
loading: boolean | null;
error?: Error;
};
export const EntityContext = createContext<EntityLoadingStatus>({
entity: undefined as any,
loading: null,
error: undefined,
});
export const useEntityFromUrl = (): EntityLoadingStatus => {
const { optionalNamespaceAndName, kind } = useParams();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const navigate = useNavigate();
@@ -36,7 +47,7 @@ export const useEntityFromUrl = () => {
);
useEffect(() => {
if (!error && !loading && !entity) {
if (error || (!loading && !entity)) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
@@ -46,10 +57,21 @@ export const useEntityFromUrl = () => {
if (!name) {
navigate('/catalog');
return { entity: null, loading: null, error: new Error('No name in url') };
return {
entity: undefined,
loading: null,
error: new Error('No name in url'),
} as never;
}
return { entity, loading, error };
};
export const useEntity = () => useContext(EntityContext);
/**
* Always going to return an entity, or throw an error if not a descendant of a EntityProvider.
* Otherwise the useEntityFromUrl will take care of the `undefined` entity
*/
export const useEntity = () =>
useContext<Omit<EntityLoadingStatus, 'entity'> & { entity: Entity }>(
EntityContext as any,
);
+2 -1
View File
@@ -21,4 +21,5 @@ export * from './routes';
export { useEntityCompoundName } from './components/useEntityCompoundName';
export * from './Router';
export { useEntity } from './hooks/useEntity';
export { EntityPageTabs } from './components/EntityPageTabs';
export { EntityMetadataCard } from './components/EntityMetadataCard/EntityMetadataCard';
export { EntityPageLayout } from './components/EntityPageLayout';
-8
View File
@@ -15,15 +15,7 @@
*/
import { createPlugin } from '@backstage/core';
import { CatalogPage } from './components/CatalogPage/CatalogPage';
import { EntityPage } from './components/EntityPage/EntityPage';
import { entityRoute, rootRoute, entityRouteDefault } from './routes';
export const plugin = createPlugin({
id: 'catalog',
register({ router }) {
router.addRoute(rootRoute, CatalogPage);
router.addRoute(entityRoute, EntityPage);
router.addRoute(entityRouteDefault, EntityPage);
},
});
+1 -1
View File
@@ -30,6 +30,6 @@ export const entityRoute = createRouteRef({
});
export const entityRouteDefault = createRouteRef({
icon: NoIcon,
path: ':kind/:optionalNamespaceAndName',
path: ':kind/:optionalNamespaceAndName/*',
title: 'Entity',
});
+25 -12
View File
@@ -19,16 +19,29 @@ import { Routes, Route } from 'react-router';
import { rootRouteRef, buildRouteRef } from './plugin';
import { WorkflowRunDetails } from './components/WorkflowRunDetails';
import { WorkflowRunsTable } from './components/WorkflowRunsTable';
import { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName';
import { WarningPanel } from '@backstage/core';
export const GitHubActionsPlugin = ({ entity }: { entity: Entity }) => (
<Routes>
<Route
path={`/${rootRouteRef.path}`}
element={<WorkflowRunsTable entity={entity} />}
/>
<Route
path={`/${buildRouteRef.path}`}
element={<WorkflowRunDetails entity={entity} />}
/>
</Routes>
);
const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity?.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION]) &&
entity?.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION] !== '';
export const GitHubActionsPage = ({ entity }: { entity: Entity }) =>
!isPluginApplicableToEntity(entity) ? (
<WarningPanel title=" GitHubActions plugin:">
`entity.metadata.annotations['
{GITHUB_ACTIONS_ANNOTATION}']` key is missing on the entity.{' '}
</WarningPanel>
) : (
<Routes>
<Route
path={`/${rootRouteRef.path}`}
element={<WorkflowRunsTable entity={entity} />}
/>
<Route
path={`/${buildRouteRef.path}`}
element={<WorkflowRunDetails entity={entity} />}
/>
)
</Routes>
);
+1 -1
View File
@@ -17,5 +17,5 @@
export { plugin } from './plugin';
export * from './api';
export { Widget } from './components/Widget';
export { GitHubActionsPlugin } from './Router';
export { GitHubActionsPage } from './Router';
export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName';
-1
View File
@@ -29,5 +29,4 @@ export const buildRouteRef = createRouteRef({
export const plugin = createPlugin({
id: 'github-actions',
register() {},
});