Merge pull request #2076 from spotify/shmidt-i/app-catalog-tabs-routes-everything-is-connected

APP-driven catalog entity page with GitHub Actions integration & routes & tabs
This commit is contained in:
Ivan Shmidt
2020-09-03 14:06:44 +02:00
committed by GitHub
45 changed files with 1122 additions and 591 deletions
+7
View File
@@ -26,3 +26,10 @@ This helps the community know what plugins are in development.
You can also use this process if you have an idea for a good plugin but you hope
that someone else will pick up the work.
## Integrate into the Service Catalog
If your plugin isn't supposed to live as a standalone page, but rather needs to
be presented as a part of a Service Catalog (e.g. a separate tab or a card on an
"Overview" tab), then check out
[the instruction](integrating-plugin-into-service-catalog.md). on how to do it.
@@ -0,0 +1,124 @@
---
id: integrating-plugin-into-service-catalog
title: Integrate into the Service Catalog
---
> This is an advanced use case and currently is an experimental feature. Expect
> API to change over time
## Steps
1. [Create a plugin](#create-a-plugin)
1. [Export a router with relative routes](#export-a-router)
1. [Import and use router in the APP](#import-and-use-router-in-the-app)
### Create a plugin
Follow the [same process](create-a-plugin.md) as for standalone plugin. You
should have a separate package in a folder, which represents your plugin.
Example:
```
$ yarn create-plugin
> ? Enter an ID for the plugin [required] my-plugin
> ? Enter the owner(s) of the plugin. If specified, this will be added to CODEOWNERS for the plugin path. [optional]
Creating the plugin...
```
### Export a router
Now in the plugin you have a `Router.tsx` file in the `src` folder. By default
it contains only one example route. Create a routing structure needed for your
plugin, keeping in mind that the whole set of routes defined here are going to
be mounted under some different route in the App.
Example:
`my-plugin` consists of 2 different views - `/me` and `/about`. I envision
people integrating it into plugin catalog as a tab named "MyPlugin". Then, my
`Routes.tsx` for the plugin is going to look like:
```tsx
<Routes>
<Route path="/me" element={<MePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
```
(where MePage and AboutPage are 2 components defined in your plugin and imported
accordingly inside `Router.tsx`)
> Pay attention, if your `MePage` references the `AboutPage` it needs to do it
> through link to `about`, not `/about`. This allows react-router v6 to enable
> its relative routing mechanism. Read more -
> https://reacttraining.com/blog/react-router-v6-pre/#relative-route-path-and-link-to
### Import and use router in the APP
In the `app/src/components/catalog/EntityPage.tsx` (app === your folder,
containing backstage app) import your created Router:
```tsx
import { Router as MyPluginRouter } from '@backstage/plugin-my-plugin;
```
Now, you need to mount `MyPluginRouter` onto some route, for example if you had:
```tsx
const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewPage entity={entity} />}
/>
</EntityPageLayout>
);
```
after you add your code it becomes:
```tsx
const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewPage entity={entity} />}
/>
<EntityPageLayout.Content
path="/my-plugin"
title="My Plugin"
element={<MyPluginRouter entity={entity} />}
/>
</EntityPageLayout>
);
```
All of magic happens thanks to the `EntityPageLayout` component, which comes as
an export from `@backstage/plugin-catalog` package.
```tsx
type EntityPageLayoutContentProps = {
/**
* Going to be transformed into react-router v6
* path under the hood. Read more at https://reacttraining.com/blog/react-router-v6-pre
*/
path: string;
/**
* Gets transformed into the title for the tab
*/
title: string;
/**
* Element that is rendered when the location
* matches the path provided
*/
element: JSX.Element;
};
```
> You can either pass the entity from App to the plugin's router as a prop or
> use `useEntity` hook from `@backstage/plugin-catalog` directly inside your
> plugin.
+1
View File
@@ -74,6 +74,7 @@
"plugins/create-a-plugin",
"plugins/plugin-development",
"plugins/structure-of-a-plugin",
"plugins/integrating-plugin-into-service-catalog",
{
"type": "subcategory",
"label": "Backends and APIs",
+1
View File
@@ -5,6 +5,7 @@
"bundled": true,
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-api-docs": "^0.1.1-alpha.21",
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
+16 -1
View File
@@ -26,6 +26,10 @@ import * as plugins from './plugins';
import { apis } from './apis';
import { hot } from 'react-hot-loader/root';
import { providers } from './identityProviders';
import { Router as CatalogRouter } from '@backstage/plugin-catalog';
import { Route, Routes, Navigate } from 'react-router';
import { EntityPage } from './components/catalog/EntityPage';
const app = createApp({
apis,
@@ -46,7 +50,18 @@ const app = createApp({
const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const deprecatedAppRoutes = app.getRoutes();
const AppRoutes = () => (
<Routes>
<Route
path="/catalog/*"
element={<CatalogRouter EntityPage={EntityPage} />}
/>
<Navigate key="/" to="/catalog" />
{...deprecatedAppRoutes}
</Routes>
);
const App: FC<{}> = () => (
<AppProvider>
+1 -1
View File
@@ -90,7 +90,7 @@ const Root: FC<{}> = ({ children }) => (
<SidebarSearchField onSearch={handleSearch} />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="./" text="Home" />
<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
<SidebarItem icon={ExploreIcon} to="explore" text="Explore" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
@@ -0,0 +1,84 @@
/*
* 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 { Router as GitHubActionsRouter } from '@backstage/plugin-github-actions';
import React from 'react';
import {
EntityPageLayout,
useEntity,
AboutCard,
} from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { Grid } from '@material-ui/core';
const OverviewContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3}>
<Grid item>
<AboutCard entity={entity} />
</Grid>
</Grid>
);
const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
title="CI/CD"
element={<GitHubActionsRouter entity={entity} />}
/>
</EntityPageLayout>
);
const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
title="CI/CD"
element={<GitHubActionsRouter entity={entity} />}
/>
</EntityPageLayout>
);
const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<OverviewContent 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} />;
}
};
+6 -1
View File
@@ -56,7 +56,12 @@ module.exports = {
'@typescript-eslint/no-unused-expressions': 'error',
'@typescript-eslint/no-unused-vars': [
'warn',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
{
vars: 'all',
args: 'after-used',
ignoreRestSiblings: true,
argsIgnorePattern: '^_',
},
],
'no-restricted-imports': [
2,
+3 -8
View File
@@ -136,7 +136,7 @@ export class PrivateAppImpl implements BackstageApp {
return this.icons[key];
}
getRoutes(): ComponentType<{}> {
getRoutes(): JSX.Element[] {
const routes = new Array<JSX.Element>();
const registeredFeatureFlags = new Array<FeatureFlagsRegistryItem>();
@@ -191,14 +191,9 @@ export class PrivateAppImpl implements BackstageApp {
FeatureFlags.registeredFeatureFlags = registeredFeatureFlags;
}
const rendered = (
<Routes>
{routes}
<Route element={<NotFoundErrorPage />} />
</Routes>
);
routes.push(<Route element={<NotFoundErrorPage />} />);
return () => rendered;
return routes;
}
getProvider(): ComponentType<{}> {
+1 -1
View File
@@ -168,5 +168,5 @@ export type BackstageApp = {
/**
* Routes component that contains all routes for plugin pages in the app.
*/
getRoutes(): ComponentType<{}>;
getRoutes(): JSX.Element[];
};
@@ -12,6 +12,7 @@
"@backstage/plugin-register-component": "^{{version}}",
"@backstage/plugin-scaffolder": "^{{version}}",
"@backstage/plugin-techdocs": "^{{version}}",
"@backstage/catalog-model": "^{{version}}",
"@backstage/plugin-circleci": "^{{version}}",
"@backstage/plugin-explore": "^{{version}}",
"@backstage/plugin-lighthouse": "^{{version}}",
@@ -8,6 +8,9 @@ import {
import { apis } from './apis';
import * as plugins from './plugins';
import { AppSidebar } from './sidebar';
import { Route, Routes, Navigate } from 'react-router';
import { Router as CatalogRouter } from '@backstage/plugin-catalog';
import { EntityPage } from './components/catalog/EntityPage';
const app = createApp({
apis,
@@ -16,7 +19,7 @@ const app = createApp({
const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const deprecatedAppRoutes = app.getRoutes();
const App: FC<{}> = () => (
<AppProvider>
@@ -25,7 +28,14 @@ const App: FC<{}> = () => (
<AppRouter>
<SidebarPage>
<AppSidebar />
<AppRoutes />
<Routes>
<Route
path="/catalog/*"
element={<CatalogRouter EntityPage={EntityPage} />}
/>
<Navigate key="/" to="/catalog" />
{deprecatedAppRoutes}
</Routes>
</SidebarPage>
</AppRouter>
</AppProvider>
@@ -0,0 +1,79 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Router as GitHubActionsRouter } from '@backstage/plugin-github-actions';
import React from 'react';
import {
EntityPageLayout,
useEntity,
AboutCard,
} from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
const OverviewContent = ({ entity }: { entity: Entity }) => (
<AboutCard entity={entity} />
);
const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
title="CI/CD"
element={<GitHubActionsRouter entity={entity} />}
/>
</EntityPageLayout>
);
const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
title="CI/CD"
element={<GitHubActionsRouter entity={entity} />}
/>
</EntityPageLayout>
);
const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<OverviewContent 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} />;
}
};
+2 -2
View File
@@ -85,7 +85,7 @@ class DevAppBuilder {
const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const deprecatedAppRoutes = app.getRoutes();
const sidebar = this.setupSidebar(this.plugins);
@@ -99,7 +99,7 @@ class DevAppBuilder {
<AppRouter>
<SidebarPage>
{sidebar}
<AppRoutes />
{deprecatedAppRoutes}
</SidebarPage>
</AppRouter>
</AppProvider>
+3
View File
@@ -63,6 +63,9 @@ async function main() {
print('All tests successful, removing test dir');
await fs.remove(rootDir);
// Just in case some child process was left hanging
process.exit(0);
}
/**
+3 -1
View File
@@ -35,10 +35,12 @@
"moment": "^2.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-helmet": "6.1.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"swr": "^0.3.0"
"swr": "^0.3.0",
"@types/react": "^16.9"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.21",
@@ -13,5 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { WorkflowRunsPage } from './WorkflowRunsPage';
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,233 +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 { EntityPageCi } from '../EntityPageCi/EntityPageCi';
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',
content: (e: Entity) => <EntityPageCi entity={e} />,
},
{
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>
);
};
@@ -14,9 +14,10 @@
* limitations under the License.
*/
// TODO(shmidt-i): move to the app
import { Entity } from '@backstage/catalog-model';
import { Content } from '@backstage/core';
import { RecentWorkflowRunsCard as GithubActionsListWidget } from '@backstage/plugin-github-actions';
import { LatestWorkflowsForBranchCard } from '@backstage/plugin-github-actions';
import { Grid } from '@material-ui/core';
import React, { FC } from 'react';
@@ -26,7 +27,7 @@ export const EntityPageCi: FC<{ entity: Entity }> = ({ entity }) => {
<Grid container spacing={3}>
{entity.metadata?.annotations?.['backstage.io/github-actions-id'] && (
<Grid item sm={12}>
<GithubActionsListWidget entity={entity} branch="master" />
<LatestWorkflowsForBranchCard entity={entity} branch="master" />
</Grid>
)}
</Grid>
@@ -0,0 +1,142 @@
/*
* 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, useContext } from 'react';
import { useParams, useNavigate } from 'react-router';
import { EntityContext } 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 } = useContext(EntityContext);
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,198 @@
/*
* 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 { Tabbed } from './Tabbed';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { Routes, Route } from 'react-router';
describe('Tabbed layout', () => {
it('renders simplest case', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="*"
element={<div>tabbed-test-content</div>}
/>
</Tabbed.Layout>,
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
});
it('throws if any other component is a child of Tabbed.Layout', async () => {
await expect(
renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="*"
element={<div>tabbed-test-content</div>}
/>
<div>This will cause app to throw</div>
</Tabbed.Layout>,
),
).rejects.toThrow(/This component only accepts/);
});
it('navigates when user clicks different tab', async () => {
const rendered = await renderInTestApp(
<Routes>
<Route
path="/*"
element={
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>
}
/>
</Routes>,
);
const secondTab = rendered.queryAllByRole('tab')[1];
act(() => {
fireEvent.click(secondTab);
});
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
describe('correctly delegates nested links', () => {
const renderRoute = (route: string) =>
renderInTestApp(
<Routes>
<Route
path="/*"
element={
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path/*"
element={
<div>
tabbed-test-content-2
<Routes>
<Route
path="/nested"
element={<div>tabbed-test-nested-content-2</div>}
/>
</Routes>
</div>
}
/>
</Tabbed.Layout>
}
/>
</Routes>,
{ routeEntries: [route] },
);
it('works for nested content', async () => {
const rendered = await renderRoute('/some-other-path/nested');
expect(
rendered.queryByText('tabbed-test-content'),
).not.toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-nested-content-2'),
).toBeInTheDocument();
});
it('works for non-nested content', async () => {
const rendered = await renderRoute('/some-other-path/');
expect(
rendered.queryByText('tabbed-test-content'),
).not.toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-nested-content-2'),
).not.toBeInTheDocument();
});
});
it('shows only one tab contents at a time', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>,
{ routeEntries: ['/some-other-path'] },
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
it('redirects to the top level when no route is matching the url', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>,
{ routeEntries: ['/non-existing-path'] },
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-content-2'),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,127 @@
/*
* 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 { Helmet } from 'react-helmet';
const getSelectedIndexOrDefault = (
matchedRoute: RouteMatch,
tabs: Tab[],
defaultIndex = 0,
) => {
if (!matchedRoute) return defaultIndex;
const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path);
return ~tabIndex ? tabIndex : defaultIndex;
};
/**
* Compound component, which allows you to define layout
* for EntityPage using Tabs as a subnavigation mechanism
* Constists of 2 parts: Tabbed.Layout and Tabbed.Content.
* Takes care of: tabs, routes, document titles, spacing around content
*
* @example
* ```jsx
* <Tabbed.Layout>
* <Tabbed.Content
* title="Example tab"
* route="/example/*"
* element={<div>This is rendered under /example/anything-here route</div>}
* />
* </TabbedLayout>
* ```
*/
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;
}
if (child.type !== Tabbed.Content) {
throw new Error(
'This component only accepts Content elements as direct children. Check the code of the EntityPage.',
);
}
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
if ((routes?.[0]?.path ?? '') !== '')
routes.push({
path: '/*',
element: <Navigate to={routes[0].path!} />,
});
const [matchedRoute] =
matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? [];
const selectedIndex = getSelectedIndexOrDefault(matchedRoute, tabs);
const currentTab = tabs[selectedIndex];
const title = currentTab?.label;
const onTabChange = (index: number) =>
// Remove trailing /*
// And remove leading / for relative navigation
// Note! route resolves relative to the position in the React tree,
// not relative to current location
navigate(tabs[index].id.replace(/\/\*$/, '').replace(/^\//, ''));
const currentRouteElement = useRoutes(routes);
if (!currentTab) return null;
return (
<>
<HeaderTabs
tabs={tabs}
selectedIndex={selectedIndex}
onChange={onTabChange}
/>
<Content>
<Helmet title={title} />
{currentRouteElement}
</Content>
</>
);
},
Content: (_props: { path: string; title: string; element: JSX.Element }) =>
null,
};
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Widget, RecentWorkflowRunsCard } from './Widget';
export { Tabbed } from './Tabbed';
@@ -13,5 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { WorkflowRunDetailsPage } from './WorkflowRunDetailsPage';
export { EntityPageLayout } from './EntityPageLayout';
@@ -14,9 +14,10 @@
* limitations under the License.
*/
// TODO(shmidt-i): move to the app
import { Entity } from '@backstage/catalog-model';
import { Content } from '@backstage/core';
import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions';
import { LatestWorkflowRunCard } from '@backstage/plugin-github-actions';
import {
JenkinsBuildsWidget,
JenkinsLastBuildWidget,
@@ -48,7 +49,7 @@ export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => {
)}
{entity.metadata?.annotations?.['backstage.io/github-actions-id'] && (
<Grid item sm={3}>
<GithubActionsWidget entity={entity} branch="master" />
<LatestWorkflowRunCard entity={entity} branch="master" />
</Grid>
)}
</Grid>
@@ -0,0 +1,27 @@
/*
* 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, { ReactNode } from 'react';
import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity';
export const EntityProvider = ({ children }: { children: ReactNode }) => {
const { entity, loading, error } = useEntityFromUrl();
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';
+73
View File
@@ -0,0 +1,73 @@
/*
* 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, { ComponentType } from 'react';
import { CatalogPage } from './CatalogPage';
import { EntityPageLayout } from './EntityPageLayout';
import { Route, Routes } from 'react-router';
import { entityRoute, rootRoute } from '../routes';
import { Content } from '@backstage/core';
import { Typography, Link } from '@material-ui/core';
import { EntityProvider } from './EntityProvider';
import { useEntity } from '../hooks/useEntity';
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>
);
const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => {
const { entity } = useEntity();
// Loading and error states
if (!entity) return <EntityPageLayout />;
// Otherwise EntityPage provided from the App
// Note that EntityPage will include EntityPageLayout already
return <EntityPage />;
};
export const Router = ({
EntityPage = DefaultEntityPage,
}: {
EntityPage?: ComponentType;
}) => (
<Routes>
<Route path={`/${rootRoute.path}`} element={<CatalogPage />} />
<Route
path={`/${entityRoute.path}`}
element={
<EntityProvider>
<EntityPageSwitch EntityPage={EntityPage} />
</EntityProvider>
}
/>
</Routes>
);
+72
View File
@@ -0,0 +1,72 @@
/*
* 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 { useEffect, createContext, useContext } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useApi, errorApiRef } from '@backstage/core';
import { catalogApiRef } from '../api/types';
import { useAsync } from 'react-use';
import { Entity } from '@backstage/catalog-model';
const REDIRECT_DELAY = 2000;
type EntityLoadingStatus = {
entity?: Entity;
loading: boolean;
error?: Error;
};
export const EntityContext = createContext<EntityLoadingStatus>({
entity: undefined,
loading: true,
error: undefined,
});
export const useEntityFromUrl = (): EntityLoadingStatus => {
const { optionalNamespaceAndName, kind } = useParams();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const navigate = useNavigate();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
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);
}
if (!name) {
errorApi.post(new Error('No name provided!'));
navigate('/');
}
}, [errorApi, navigate, error, loading, entity, name]);
return { entity, loading, error };
};
/**
* Always going to return an entity, or throw an error if not a descendant of a EntityProvider.
*/
export const useEntity = () => {
const { entity } = useContext<{ entity: Entity }>(EntityContext as any);
return { entity };
};
+4
View File
@@ -19,3 +19,7 @@ export * from './api/CatalogClient';
export * from './api/types';
export * from './routes';
export { useEntityCompoundName } from './components/useEntityCompoundName';
export { Router } from './components/Router';
export { useEntity } from './hooks/useEntity';
export { AboutCard } from './components/AboutCard';
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);
},
});
+2 -7
View File
@@ -20,16 +20,11 @@ const NoIcon = () => null;
export const rootRoute = createRouteRef({
icon: NoIcon,
path: '/',
path: '',
title: 'Catalog',
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/catalog/:kind/:optionalNamespaceAndName/:selectedTabId/*',
title: 'Entity',
});
export const entityRouteDefault = createRouteRef({
icon: NoIcon,
path: '/catalog/:kind/:optionalNamespaceAndName',
path: ':kind/:optionalNamespaceAndName/*',
title: 'Entity',
});
+1
View File
@@ -35,6 +35,7 @@
"moment": "^2.27.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3"
},
@@ -74,7 +74,7 @@ const WidgetContent = ({
);
};
export const Widget = ({
export const LatestWorkflowRunCard = ({
entity,
branch = 'master',
}: {
@@ -109,50 +109,14 @@ export const Widget = ({
);
};
const RecentWorkflowRunsCardContent = ({
error,
loading,
branch,
}: {
error?: Error;
loading?: boolean;
branch: string;
}) => {
if (error) return <Typography>Couldn't fetch {branch} runs</Typography>;
if (loading) return <LinearProgress />;
return <WorkflowRunsTable />;
};
export const RecentWorkflowRunsCard = ({
export const LatestWorkflowsForBranchCard = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => {
const errorApi = useApi(errorApiRef);
const [owner, repo] = (
entity?.metadata.annotations?.['backstage.io/github-actions-id'] ?? '/'
).split('/');
const [{ loading, error }] = useWorkflowRuns({
owner,
repo,
branch,
});
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [error, errorApi]);
return (
<InfoCard title={`${branch} builds`}>
<RecentWorkflowRunsCardContent
error={error}
loading={loading}
branch={branch}
/>
</InfoCard>
);
};
}) => (
<InfoCard title={`Last ${branch} build`}>
<WorkflowRunsTable branch={branch} entity={entity} />
</InfoCard>
);
@@ -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 { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards';
@@ -0,0 +1,48 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { Routes, Route } from 'react-router';
import { rootRouteRef, buildRouteRef } from '../plugin';
import { WorkflowRunDetails } from './WorkflowRunDetails';
import { WorkflowRunsTable } from './WorkflowRunsTable';
import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName';
import { WarningPanel } from '@backstage/core';
const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]) &&
entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] !== '';
export const Router = ({ entity }: { entity: Entity }) =>
// TODO(shmidt-i): move warning to a separate standardized component
!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>
);
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import React from 'react';
import { useEntityCompoundName } from '@backstage/plugin-catalog';
import { useWorkflowRunsDetails } from './useWorkflowRunsDetails';
import { useWorkflowRunJobs } from './useWorkflowRunJobs';
import { useProjectName } from '../useProjectName';
@@ -35,13 +34,16 @@ import {
LinearProgress,
CircularProgress,
Theme,
Link,
Breadcrumbs,
Link as MaterialLink,
} from '@material-ui/core';
import { Jobs, Job, Step } from '../../api';
import moment from 'moment';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExternalLinkIcon from '@material-ui/icons/Launch';
import { Entity } from '@backstage/catalog-model';
import { Link } from '@backstage/core';
const useStyles = makeStyles<Theme>(theme => ({
root: {
@@ -140,18 +142,8 @@ const JobListItem = ({ job, className }: { job: Job; className: string }) => {
);
};
export const WorkflowRunDetails = () => {
let entityCompoundName = useEntityCompoundName();
if (!entityCompoundName.name) {
// TODO(shmidt-i): remove when is fully integrated
// into the entity view
entityCompoundName = {
kind: 'Component',
name: 'backstage',
namespace: 'default',
};
}
const projectName = useProjectName(entityCompoundName);
export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => {
const projectName = useProjectName(entity);
const [owner, repo] = projectName.value ? projectName.value.split('/') : [];
const details = useWorkflowRunsDetails(repo, owner);
@@ -170,6 +162,10 @@ export const WorkflowRunDetails = () => {
}
return (
<div className={classes.root}>
<Breadcrumbs aria-label="breadcrumb">
<Link to="..">Workflow runs</Link>
<Typography>Workflow run details</Typography>
</Breadcrumbs>
<TableContainer component={Paper} className={classes.table}>
<Table>
<TableBody>
@@ -211,10 +207,10 @@ export const WorkflowRunDetails = () => {
</TableCell>
<TableCell>
{details.value?.html_url && (
<Link target="_blank" href={details.value.html_url}>
<MaterialLink target="_blank" href={details.value.html_url}>
Workflow runs on GitHub{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</Link>
</MaterialLink>
)}
</TableCell>
</TableRow>
@@ -1,65 +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 { Typography, Grid, Breadcrumbs } from '@material-ui/core';
import React from 'react';
import {
Link,
Page,
Header,
HeaderLabel,
Content,
ContentHeader,
SupportButton,
pageTheme,
} from '@backstage/core';
import { WorkflowRunDetails } from '../WorkflowRunDetails';
/**
* A component for Jobs visualization. Jobs are a property of a Workflow Run.
*/
export const WorkflowRunDetailsPage = () => {
return (
<Page theme={pageTheme.tool}>
<Header
title="GitHub Actions"
subtitle="See recent workflow runs and their status"
>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Workflow run details">
<SupportButton>
This plugin allows you to view and interact with your builds within
the GitHub Actions environment.
</SupportButton>
</ContentHeader>
<Breadcrumbs aria-label="breadcrumb">
<Link to="/github-actions">Workflow runs</Link>
<Typography>Workflow run details</Typography>
</Breadcrumbs>
<Grid container spacing={3} direction="column">
<Grid item>
<WorkflowRunDetails />
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -1,56 +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 {
Header,
HeaderLabel,
pageTheme,
Page,
Content,
ContentHeader,
SupportButton,
} from '@backstage/core';
import { Grid } from '@material-ui/core';
import React from 'react';
import { WorkflowRunsTable } from '../WorkflowRunsTable';
export const WorkflowRunsPage = () => {
return (
<Page theme={pageTheme.tool}>
<Header
title="GitHub Actions"
subtitle="See recent workflow runs and their status"
>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Workflow runs">
<SupportButton>
This plugin allows you to view and interact with your builds within
the GitHub Actions environment.
</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<WorkflowRunsTable />
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -23,8 +23,8 @@ import { useWorkflowRuns } from '../useWorkflowRuns';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import SyncIcon from '@material-ui/icons/Sync';
import { buildRouteRef } from '../../plugin';
import { useEntityCompoundName } from '@backstage/plugin-catalog';
import { useProjectName } from '../useProjectName';
import { Entity } from '@backstage/catalog-model';
export type WorkflowRun = {
id: string;
@@ -134,6 +134,7 @@ export const WorkflowRunsTableView: FC<Props> = ({
data={runs ?? []}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangePageSize}
style={{ width: '100%' }}
title={
<Box display="flex" alignItems="center">
<GitHubIcon />
@@ -146,25 +147,21 @@ export const WorkflowRunsTableView: FC<Props> = ({
);
};
export const WorkflowRunsTable = () => {
let entityCompoundName = useEntityCompoundName();
if (!entityCompoundName.name) {
// TODO(shmidt-i): remove when is fully integrated
// into the entity view
entityCompoundName = {
kind: 'Component',
name: 'backstage',
namespace: 'default',
};
}
const { value: projectName, loading } = useProjectName(entityCompoundName);
export const WorkflowRunsTable = ({
entity,
branch,
}: {
entity: Entity;
branch?: string;
}) => {
const { value: projectName, loading } = useProjectName(entity);
const [owner, repo] = (projectName ?? '/').split('/');
const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns({
owner,
repo,
branch,
});
return (
<WorkflowRunsTableView
{...tableProps}
@@ -15,15 +15,13 @@
*/
import { useAsync } from 'react-use';
import { catalogApiRef, EntityCompoundName } from '@backstage/plugin-catalog';
import { useApi } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
export const useProjectName = (name: EntityCompoundName) => {
const catalogApi = useApi(catalogApiRef);
export const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug';
export const useProjectName = (entity: Entity) => {
const { value, loading, error } = useAsync(async () => {
const entity = await catalogApi.getEntityByName(name);
return entity?.metadata.annotations?.['github.com/project-slug'] ?? '';
return entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '';
});
return { value, loading, error };
};
+3 -1
View File
@@ -16,4 +16,6 @@
export { plugin } from './plugin';
export * from './api';
export { Widget, RecentWorkflowRunsCard } from './components/Widget';
export { Router } from './components/Router';
export * from './components/Cards';
export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName';
+3 -13
View File
@@ -15,28 +15,18 @@
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import { WorkflowRunDetailsPage } from './components/WorkflowRunDetailsPage';
import { WorkflowRunsPage } from './components/WorkflowRunsPage';
// TODO(freben): This is just a demo route for now
export const rootRouteRef = createRouteRef({
path: '/github-actions',
path: '',
title: 'GitHub Actions',
});
export const projectRouteRef = createRouteRef({
path: '/github-actions/:kind/:optionalNamespaceAndName/',
title: 'GitHub Actions for project',
});
export const buildRouteRef = createRouteRef({
path: '/github-actions/workflow-run/:id',
path: ':id',
title: 'GitHub Actions Workflow Run',
});
export const plugin = createPlugin({
id: 'github-actions',
register({ router }) {
router.addRoute(rootRouteRef, WorkflowRunsPage);
router.addRoute(projectRouteRef, WorkflowRunsPage);
router.addRoute(buildRouteRef, WorkflowRunDetailsPage);
},
});
@@ -26,7 +26,7 @@ import { useJobPolling } from './useJobPolling';
import { Job } from '../../types';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { Button } from '@backstage/core';
import { entityRouteDefault } from '@backstage/plugin-catalog';
import { entityRoute } from '@backstage/plugin-catalog';
import { generatePath } from 'react-router-dom';
type Props = {
@@ -72,7 +72,7 @@ export const JobStatusModal = ({
{entity && (
<DialogActions>
<Button
to={generatePath(entityRouteDefault.path, {
to={generatePath(entityRoute.path, {
kind: entity.kind,
optionalNamespaceAndName: [
entity.metadata.namespace,