Merge branch 'master' into ndudnik/unregister-entity

This commit is contained in:
Nikita Nek Dudnik
2020-06-10 23:44:27 +02:00
11 changed files with 224 additions and 253 deletions
+50 -79
View File
@@ -14,14 +14,14 @@
* limitations under the License.
*/
import { CatalogApi } from './types';
import { DescriptorEnvelope } from '../types';
import {
Entity,
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import Cache from 'node-cache';
import { DescriptorEnvelope } from '../types';
import { CatalogApi, EntityCompoundName } from './types';
export class CatalogClient implements CatalogApi {
// TODO(blam): This cache is just temporary until we have GraphQL.
@@ -30,6 +30,7 @@ export class CatalogClient implements CatalogApi {
private cache: Cache;
private apiOrigin: string;
private basePath: string;
constructor({
apiOrigin,
basePath,
@@ -41,46 +42,41 @@ export class CatalogClient implements CatalogApi {
this.basePath = basePath;
this.cache = new Cache({ stdTTL: 10 });
}
private async getRequired(path: string): Promise<any> {
const url = `${this.apiOrigin}${this.basePath}${path}`;
const response = await fetch(url);
if (!response.ok) {
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
return await response.json();
}
private async getOptional(path: string): Promise<any | undefined> {
const url = `${this.apiOrigin}${this.basePath}${path}`;
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
return undefined;
}
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
return await response.json();
}
async getLocationById(id: String): Promise<Location | undefined> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/locations/${id}`,
);
if (response.ok) {
const location = await response.json();
if (location) return location.data;
}
return undefined;
}
async removeEntityByUid(uid: string): Promise<void> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-uid/${uid}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
const payload = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
}
return undefined;
}
async removeLocationById(id: string): Promise<void> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/locations/${id}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
const payload = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
}
return undefined;
return await this.getOptional(`/locations/${id}`);
}
async getEntities(
filter?: Record<string, string>,
): Promise<DescriptorEnvelope[]> {
@@ -89,49 +85,25 @@ export class CatalogClient implements CatalogApi {
);
if (cachedValue) return cachedValue;
let url = `${this.apiOrigin}${this.basePath}/entities`;
let path = `/entities`;
if (filter) {
url += '?';
url += Object.entries(filter)
path += '?';
path += Object.entries(filter)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
)
.join('&');
}
const response = await fetch(url);
if (!response.ok) {
const payload = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
}
const value = await response.json();
if (value?.length) {
this.cache.set(`get:${JSON.stringify(filter)}`, value);
}
return value;
return await this.getRequired(path);
}
async getEntity({
name,
namespace,
kind,
}: {
name: string;
namespace?: string;
kind: string;
}): Promise<DescriptorEnvelope> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-name/${kind}/${
namespace ?? 'default'
}/${name}`,
);
const entity = await response.json();
if (entity) return entity;
throw new Error(`'Entity not found: ${name}`);
async getEntityByName(
compoundName: EntityCompoundName,
): Promise<Entity | undefined> {
const { kind, namespace = 'default', name } = compoundName;
return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`);
}
async addLocation(type: string, target: string) {
@@ -162,11 +134,10 @@ export class CatalogClient implements CatalogApi {
}
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION];
if (!locationId) return undefined;
const location = this.getLocationById(locationId);
return location;
const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION];
const all: { data: Location }[] = await this.getRequired('/locations');
return all
.map(r => r.data)
.find(l => locationCompound === `${l.type}:${l.target}`);
}
}
+9 -5
View File
@@ -22,13 +22,17 @@ export const catalogApiRef = createApiRef<CatalogApi>({
'Used by the Catalog plugin to make requests to accompanying backend',
});
export type EntityCompoundName = {
kind: string;
namespace?: string;
name: string;
};
export interface CatalogApi {
getEntity(params: {
name: string;
namespace?: string;
kind: string;
}): Promise<Entity>;
getLocationById(id: String): Promise<Location | undefined>;
getEntityByName(
compoundName: EntityCompoundName,
): Promise<Entity | undefined>;
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { LocationSpec, Entity } from '@backstage/catalog-model';
import { Entity, LocationSpec } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
@@ -27,21 +27,18 @@ import {
SupportButton,
useApi,
} from '@backstage/core';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
import GitHub from '@material-ui/icons/GitHub';
import StarOutline from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { Button, Link, makeStyles, Typography } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import React, { FC, useCallback, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { defaultFilter, filterGroups, entityFilters } from '../../data/filters';
import { entityToComponent, findLocationForEntityMeta } from '../../data/utils';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
CatalogFilter,
@@ -102,7 +99,7 @@ export const CatalogPage: FC<{}> = () => {
if (!location) return;
window.open(location.target, '_blank');
},
hidden: location ? location?.type !== 'github' : true,
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
@@ -125,7 +122,7 @@ export const CatalogPage: FC<{}> = () => {
if (!location) return;
window.open(createEditLink(location), '_blank');
},
hidden: location ? location?.type !== 'github' : true,
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
@@ -204,16 +201,7 @@ export const CatalogPage: FC<{}> = () => {
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
components={
(value &&
value.map(val => {
return {
...entityToComponent(val),
locationSpec: findLocationForEntityMeta(val.metadata),
};
})) ||
[]
}
entities={value || []}
loading={loading}
error={error}
actions={actions}
@@ -13,30 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import { render } from '@testing-library/react';
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import * as React from 'react';
import { CatalogTable } from './CatalogTable';
import { Component } from '../../data/component';
const components: Component[] = [
const entites: Entity[] = [
{
name: 'component1',
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'component1' },
description: 'Placeholder',
},
{
name: 'component2',
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'component2' },
description: 'Placeholder',
},
{
name: 'component3',
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'component3' },
description: 'Placeholder',
},
];
@@ -46,7 +43,7 @@ describe('CatalogTable component', () => {
wrapInTestApp(
<CatalogTable
titlePreamble="Owned"
components={[]}
entities={[]}
loading={false}
error={{ code: 'error' }}
/>,
@@ -63,13 +60,13 @@ describe('CatalogTable component', () => {
wrapInTestApp(
<CatalogTable
titlePreamble="Owned"
components={components}
entities={entites}
loading={false}
/>,
),
);
expect(
await rendered.findByText(`Owned (${components.length})`),
await rendered.findByText(`Owned (${entites.length})`),
).toBeInTheDocument();
expect(await rendered.findByText('component1')).toBeInTheDocument();
expect(await rendered.findByText('component2')).toBeInTheDocument();
@@ -13,33 +13,33 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC } from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { Component } from '../../data/component';
import { entityRoute } from '../../routes';
const columns: TableColumn[] = [
{
title: 'Name',
field: 'name',
field: 'metadata.name',
highlight: true,
render: (componentData: any) => (
render: (entity: any) => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
optionalNamespaceAndName: [
componentData.namespace,
componentData.name,
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: componentData.kind,
kind: entity.kind,
})}
>
{componentData.name}
{entity.metadata.name}
</Link>
),
},
@@ -49,12 +49,12 @@ const columns: TableColumn[] = [
},
{
title: 'Description',
field: 'description',
field: 'metadata.description',
},
];
type CatalogTableProps = {
components: Component[];
entities: Entity[];
titlePreamble: string;
loading: boolean;
error?: any;
@@ -62,7 +62,7 @@ type CatalogTableProps = {
};
export const CatalogTable: FC<CatalogTableProps> = ({
components,
entities,
loading,
error,
titlePreamble,
@@ -88,8 +88,8 @@ export const CatalogTable: FC<CatalogTableProps> = ({
loadingType: 'linear',
showEmptyDataSourceMessage: !loading,
}}
title={`${titlePreamble} (${(components && components.length) || 0})`}
data={components}
title={`${titlePreamble} (${(entities && entities.length) || 0})`}
data={entities}
actions={actions}
/>
);
@@ -13,29 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import ComponentMetadataCard from './ComponentMetadataCard';
import { Entity } from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import { Entity } from '../../../../../packages/catalog-model/src/entity/Entity';
import React from 'react';
import { ComponentMetadataCard } from './ComponentMetadataCard';
describe('ComponentMetadataCard component', () => {
it('should display component name if provided', async () => {
const testEntity: Entity = {
apiVersion: '',
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: {
name: 'test',
},
metadata: { name: 'test' },
};
const rendered = await render(
<ComponentMetadataCard loading={false} entity={testEntity} />,
<ComponentMetadataCard entity={testEntity} />,
);
expect(await rendered.findByText('test')).toBeInTheDocument();
});
it('should display loader when loading is set to true', async () => {
const rendered = await render(
<ComponentMetadataCard loading entity={undefined} />,
);
expect(await rendered.findByRole('progressbar')).toBeInTheDocument();
});
});
@@ -13,32 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { Progress, InfoCard, StructuredMetadataTable } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
import React, { FC } from 'react';
type ComponentMetadataCardProps = {
loading: boolean;
entity: Entity | undefined;
type Props = {
entity: Entity;
};
const ComponentMetadataCard: FC<ComponentMetadataCardProps> = ({
loading,
entity,
}) => {
if (loading) {
return (
<InfoCard title="Metadata">
<Progress />
</InfoCard>
);
}
if (!entity) {
return null;
}
return (
<InfoCard title="Metadata">
<StructuredMetadataTable metadata={entity} />
</InfoCard>
);
};
export default ComponentMetadataCard;
export const ComponentMetadataCard: FC<Props> = ({ entity }) => (
<InfoCard title="Metadata">
<StructuredMetadataTable metadata={entity.metadata} />
</InfoCard>
);
@@ -47,8 +47,8 @@ describe('ComponentPage', () => {
[
catalogApiRef,
({
async getEntity() {},
} as unknown) as CatalogApi,
async getEntityByName() {},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
@@ -13,26 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import ComponentMetadataCard from '../ComponentMetadataCard/ComponentMetadataCard';
import { Entity } from '@backstage/catalog-model';
import {
Content,
Header,
pageTheme,
Page,
useApi,
ErrorApi,
errorApiRef,
Header,
HeaderTabs,
Page,
pageTheme,
Progress,
useApi,
} from '@backstage/core';
import ComponentContextMenu from '../ComponentContextMenu/ComponentContextMenu';
import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDialog';
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Grid } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { Entity } from '@backstage/catalog-model';
import { ComponentContextMenu } from '../ComponentContextMenu/ComponentContextMenu';
import { ComponentMetadataCard } from '../ComponentMetadataCard/ComponentMetadataCard';
import { ComponentRemovalDialog } from '../ComponentRemovalDialog/ComponentRemovalDialog';
const REDIRECT_DELAY = 1000;
@@ -48,42 +49,59 @@ type ComponentPageProps = {
};
};
const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [removingPending, setRemovingPending] = useState(false);
const showRemovalDialog = () => setConfirmationDialogOpen(true);
const hideRemovalDialog = () => setConfirmationDialogOpen(false);
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 ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
const { optionalNamespaceAndName, kind } = match.params;
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi<ErrorApi>(errorApiRef);
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: component, error, loading } = useAsync<Component>(async () => {
const entity = await catalogApi.getEntity({ name, namespace, kind });
const location = await catalogApi.getLocationByEntity(entity);
return { ...entityToComponent(entity), location };
});
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const { value: entity, error, loading } = useAsync<Entity | undefined>(
() => catalogApi.getEntityByName({ kind, namespace, name }),
[catalogApi, kind, namespace, name],
);
useEffect(() => {
if (error) {
if (!error && !loading && !entity) {
errorApi.post(new Error('Component not found!'));
setTimeout(() => {
history.push('/');
}, REDIRECT_DELAY);
}
}, [error, errorApi, history]);
}, [errorApi, history, error, loading, entity]);
if (name === '') {
if (!name) {
history.push('/catalog');
return null;
}
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
setRemovingPending(true);
history.push('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
// TODO - Replace with proper tabs implementation
const tabs = [
{
@@ -112,39 +130,56 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
},
];
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity,
);
return (
// TODO: Switch theme and type props based on component type (website, library, ...)
<Page theme={pageTheme.service}>
<Header title={entity?.metadata.name || 'Catalog'}>
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
<Header title={headerTitle} type={headerType}>
{entity && (
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
)}
</Header>
<HeaderTabs tabs={tabs} />
{confirmationDialogOpen && entity && (
<ComponentRemovalDialog
entity={entity}
onClose={hideRemovalDialog}
onConfirm={cleanUpAfterRemoval}
onCancel={hideRemovalDialog}
/>
{loading && <Progress />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{entity && (
<>
<HeaderTabs tabs={tabs} />
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<ComponentMetadataCard entity={entity} />
</Grid>
<Grid item>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
/>
</Grid>
</Grid>
</Content>
<ComponentRemovalDialog
open={confirmationDialogOpen}
entity={entity}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</>
)}
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<ComponentMetadataCard
loading={loading || removingPending}
entity={entity}
/>
</Grid>
<Grid item>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
/>
</Grid>
</Grid>
</Content>
</Page>
);
};
export default ComponentPage;
@@ -26,19 +26,17 @@ import {
Typography,
useMediaQuery,
useTheme,
List,
ListItem,
ListItemText,
} from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import React, { FC } from 'react';
import { useAsync } from 'react-use';
import { AsyncState } from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api/types';
import { alertApiRef } from '../../../../../packages/core-api/src/apis/definitions/AlertApi';
type ComponentRemovalDialogProps = {
open: boolean;
onConfirm: () => any;
onCancel: () => any;
onClose: () => any;
entity: Entity;
};
@@ -54,51 +52,51 @@ function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
}
export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
open,
onConfirm,
onCancel,
onClose,
entity,
}) => {
const catalogApi = useApi(catalogApiRef);
const alertApi = useApi(alertApiRef);
const { value: entities, loading, error } = useColocatedEntities(entity);
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const catalogApi = useApi(catalogApiRef);
const alertApi = useApi(alertApiRef);
return (
<Dialog fullScreen={fullScreen} open onClose={onClose}>
<Dialog fullScreen={fullScreen} open={open} onClose={onClose}>
<DialogTitle id="responsive-dialog-title">
Are you sure you want to unregister this component?
</DialogTitle>
<DialogContent>
{loading ? <Progress /> : null}
{error ? (
<DialogContentText>{error.toString()}</DialogContentText>
<Alert severity="error" style={{ wordBreak: 'break-word' }}>
{error.toString()}
</Alert>
) : null}
{entities ? (
<>
<DialogContentText>
This action will unregister the following entities:
</DialogContentText>
<List dense>
{entities.map(e => (
<ListItem key={e.metadata.name}>
<ListItemText primary={e.metadata.name} />
</ListItem>
))}
</List>
<Typography component="div">
<ul>
{entities.map(e => (
<li key={e.metadata.name}>{e.metadata.name}</li>
))}
</ul>
</Typography>
<DialogContentText>
That are located at the following location:
</DialogContentText>
<List dense>
<ListItem>
<ListItemText
primary={
entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]
}
/>
</ListItem>
</List>
<Typography component="div">
<ul>
<li>
{entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]}
</li>
</ul>
</Typography>
<DialogContentText>
To undo, just re-register the component in Backstage.
</DialogContentText>
@@ -106,7 +104,7 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
) : null}
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="primary">
<Button onClick={onClose} color="primary">
Cancel
</Button>
<Button
@@ -125,7 +123,7 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
onConfirm();
}}
color="primary"
color="secondary"
>
Unregister
</Button>
@@ -24,6 +24,7 @@ import { catalogApiRef } from '@backstage/plugin-catalog';
import { MemoryRouter } from 'react-router-dom';
const errorApi = { post: () => {} };
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
@@ -49,6 +50,7 @@ const setup = () => ({
</MemoryRouter>,
),
});
describe('RegisterComponentPage', () => {
afterEach(() => cleanup());