Merge pull request #1216 from spotify/ndudnik/unregister-entity

Unregister entity
This commit is contained in:
Nikita Dudnik
2020-06-11 12:03:53 +02:00
committed by GitHub
17 changed files with 130 additions and 61 deletions
@@ -28,6 +28,7 @@ describe('DatabaseEntitiesCatalog', () => {
updateEntity: jest.fn(),
entities: jest.fn(),
entity: jest.fn(),
entityByUid: jest.fn(),
removeEntity: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
@@ -15,6 +15,9 @@
*/
import type { Entity } from '@backstage/catalog-model';
import { LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { NotFoundError } from '@backstage/backend-common';
import type { Database, DbEntityResponse, EntityFilters } from '../database';
import type { EntitiesCatalog } from './types';
@@ -78,7 +81,28 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
async removeEntityByUid(uid: string): Promise<void> {
return await this.database.transaction(async tx => {
await this.database.removeEntity(tx, uid);
const entityResponse = await this.database.entityByUid(tx, uid);
if (!entityResponse) {
throw new NotFoundError(`Entity with ID ${uid} was not found`);
}
const location =
entityResponse.entity.metadata.annotations?.[LOCATION_ANNOTATION];
const colocatedEntities = location
? await this.database.entities(tx, [
{
key: LOCATION_ANNOTATION,
values: [location],
},
])
: [entityResponse];
for (const dbResponse of colocatedEntities) {
await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!);
}
if (entityResponse.locationId) {
await this.database.removeLocation(tx, entityResponse?.locationId!);
}
return undefined;
});
}
@@ -31,7 +31,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog {
}
async removeLocation(id: string): Promise<void> {
await this.database.removeLocation(id);
await this.database.transaction(tx => this.database.removeLocation(tx, id));
}
async locations(): Promise<LocationResponse[]> {
@@ -105,8 +105,7 @@ describe('CommonDatabase', () => {
expect(locations).toEqual([output]);
const location = await db.location(locations[0].id);
expect(location).toEqual(output);
await db.removeLocation(locations[0].id);
await db.transaction(tx => db.removeLocation(tx, locations[0].id));
await expect(db.locations()).resolves.toEqual([]);
await expect(db.location(locations[0].id)).rejects.toThrow(
@@ -319,6 +319,21 @@ export class CommonDatabase implements Database {
return toEntityResponse(rows[0]);
}
async entityByUid(
txOpaque: unknown,
id: string,
): Promise<DbEntityResponse | undefined> {
const tx = txOpaque as Knex.Transaction<any, any>;
const rows = await tx<DbEntitiesRow>('entities').where({ id }).select();
if (rows.length !== 1) {
return undefined;
}
return toEntityResponse(rows[0]);
}
async removeEntity(txOpaque: unknown, uid: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
@@ -341,10 +356,10 @@ export class CommonDatabase implements Database {
});
}
async removeLocation(id: string): Promise<void> {
const result = await this.database<DbLocationsRow>('locations')
.where({ id })
.del();
async removeLocation(txOpaque: unknown, id: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
const result = await tx<DbLocationsRow>('locations').where({ id }).del();
if (!result) {
throw new NotFoundError(`Found no location with ID ${id}`);
@@ -130,11 +130,13 @@ export type Database = {
namespace?: string,
): Promise<DbEntityResponse | undefined>;
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
removeEntity(tx: unknown, uid: string): Promise<void>;
addLocation(location: Location): Promise<DbLocationsRow>;
removeLocation(id: string): Promise<void>;
removeLocation(tx: unknown, id: string): Promise<void>;
location(id: string): Promise<DbLocationsRowWithStatus>;
+16
View File
@@ -134,4 +134,20 @@ export class CatalogClient implements CatalogApi {
.map(r => r.data)
.find(l => locationCompound === `${l.type}:${l.target}`);
}
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;
}
}
+1
View File
@@ -36,6 +36,7 @@ export interface CatalogApi {
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
removeEntityByUid(uid: string): Promise<void>;
}
export type AddLocationResponse = { location: Location; entities: Entity[] };
@@ -18,7 +18,7 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
import { FilterGroupItem } from '../../types';
import { EntityFilterType } from '../../data/filters';
describe('Catalog Filter', () => {
it('should render the different groups', async () => {
@@ -41,11 +41,11 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
},
],
@@ -68,12 +68,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: 100,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -97,12 +97,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: 100,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -136,12 +136,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: () => <b>BACKSTAGE!</b>,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -25,9 +25,9 @@ import {
makeStyles,
} from '@material-ui/core';
import type { IconComponent } from '@backstage/core';
import { FilterGroupItem } from '../../types';
import { EntityFilterType } from '../../data/filters';
export type CatalogFilterItem = {
id: FilterGroupItem;
id: EntityFilterType;
label: string;
icon?: IconComponent;
count?: number | React.FC;
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
@@ -28,6 +27,7 @@ import React from 'react';
import { catalogApiRef } from '../..';
import { CatalogApi } from '../../api/types';
import { CatalogPage } from './CatalogPage';
import { Entity } from '@backstage/catalog-model';
describe('CatalogPage', () => {
const mockErrorApi = new MockErrorApi();
@@ -37,7 +37,7 @@ import React, { FC, useCallback, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { dataResolvers, defaultFilter, filterGroups } from '../../data/filters';
import { defaultFilter, filterGroups, entityFilters } from '../../data/filters';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
@@ -71,7 +71,14 @@ export const CatalogPage: FC<{}> = () => {
);
const { value, error, loading } = useAsync(
() => dataResolvers[selectedFilter.id]({ catalogApi, isStarredEntity }),
() =>
catalogApi
.getEntities()
.then(entities =>
entities.filter(
entityFilters[selectedFilter.id]({ isStarredEntity }),
),
),
[selectedFilter.id, starredEntities.size],
);
@@ -95,14 +95,12 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
return null;
}
const removeComponent = async () => {
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
// await componentFactory.removeComponentByName(componentName);
history.push('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
const hideRemovalDialog = () => setConfirmationDialogOpen(false);
// TODO - Replace with proper tabs implementation
const tabs = [
@@ -177,8 +175,8 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
<ComponentRemovalDialog
open={confirmationDialogOpen}
entity={entity}
onClose={hideRemovalDialog}
onConfirm={removeComponent}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</>
)}
@@ -15,7 +15,7 @@
*/
import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { Progress, useApi } from '@backstage/core';
import { Progress, useApi, alertApiRef } from '@backstage/core';
import {
Button,
Dialog,
@@ -59,6 +59,19 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
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);
const removeEntity = async () => {
const uid = entity.metadata.uid;
try {
await catalogApi.removeEntityByUid(uid!);
} catch (err) {
alertApi.post({ message: err.message });
}
onConfirm();
};
return (
<Dialog fullScreen={fullScreen} open={open} onClose={onClose}>
@@ -90,7 +103,7 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
<Typography component="div">
<ul>
<li>
{entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]}
{entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]}
</li>
</ul>
</Typography>
@@ -101,10 +114,12 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
) : null}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onClose} color="primary">
Cancel
</Button>
<Button
disabled={!!(loading || error)}
onClick={onConfirm}
onClick={removeEntity}
color="secondary"
>
Unregister
+18 -22
View File
@@ -21,22 +21,26 @@ import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { StarredCount } from '../components/CatalogFilter/StarredCount';
import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount';
import { FilterGroupItem } from '../types';
import { CatalogApi } from '../..';
import { Entity } from '@backstage/catalog-model';
export enum EntityFilterType {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
export const filterGroups: CatalogFilterGroup[] = [
{
name: 'Personal',
items: [
{
id: FilterGroupItem.OWNED,
id: EntityFilterType.OWNED,
label: 'Owned',
count: 0,
icon: SettingsIcon,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Starred',
count: StarredCount,
icon: StarIcon,
@@ -48,7 +52,7 @@ export const filterGroups: CatalogFilterGroup[] = [
name: 'Company',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'All Services',
count: AllServicesCount,
},
@@ -56,24 +60,16 @@ export const filterGroups: CatalogFilterGroup[] = [
},
];
type ResolverFunction = ({
catalogApi,
isStarredEntity,
}: {
catalogApi: CatalogApi;
isStarredEntity: (entity: Entity) => boolean;
}) => Promise<Entity[]>;
type EntityFilter = (entity: Entity) => boolean;
type EntityFilterOptions = {
isStarredEntity: EntityFilter;
};
export const dataResolvers: Record<FilterGroupItem, ResolverFunction> = {
[FilterGroupItem.OWNED]: async () => [],
[FilterGroupItem.ALL]: async ({ catalogApi }) => {
return catalogApi.getEntities();
},
[FilterGroupItem.STARRED]: async ({ catalogApi, isStarredEntity }) => {
const allEntities = await catalogApi.getEntities();
return allEntities.filter(entity => isStarredEntity(entity));
},
export const entityFilters = {
[EntityFilterType.OWNED]: (): EntityFilter => () => false,
[EntityFilterType.ALL]: (): EntityFilter => () => true,
[EntityFilterType.STARRED]: ({ isStarredEntity }: EntityFilterOptions) =>
isStarredEntity,
};
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
-6
View File
@@ -157,9 +157,3 @@ export type KindParser = {
envelope: DescriptorEnvelope,
): Promise<DescriptorEnvelope | undefined>;
};
export enum FilterGroupItem {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
@@ -29,9 +29,10 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
getEntities: jest.fn(),
getEntityByName: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const setup = () => ({