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:
@@ -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",
|
||||
|
||||
+1
-2
@@ -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,
|
||||
};
|
||||
+1
-1
@@ -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';
|
||||
+1
-2
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+7
-43
@@ -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>
|
||||
|
||||
-65
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user