diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index eb9cb2439c..6de0aa90a5 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -28,6 +28,7 @@ describe('DatabaseEntitiesCatalog', () => { updateEntity: jest.fn(), entities: jest.fn(), entity: jest.fn(), + entityById: jest.fn(), removeEntity: jest.fn(), addLocation: jest.fn(), removeLocation: jest.fn(), diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 1ec1ebf6e5..9c3f87055b 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -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,30 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { async removeEntityByUid(uid: string): Promise { return await this.database.transaction(async tx => { - await this.database.removeEntity(tx, uid); + const currentEntity = await this.database.entityById(tx, uid); + if (!currentEntity) { + throw new NotFoundError(`Entity with ID ${uid} was not found`); + } + const colocatedEntities = currentEntity?.entity.metadata.annotations?.[ + LOCATION_ANNOTATION + ] + ? await this.database.entities(tx, [ + { + key: LOCATION_ANNOTATION, + values: [ + currentEntity.entity.metadata.annotations[LOCATION_ANNOTATION], + ], + }, + ]) + : [currentEntity]; + for (const dbResponse of colocatedEntities) { + await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!); + } + + if (currentEntity?.locationId) { + await this.database.removeLocation(tx, currentEntity?.locationId!); + } + return Promise.resolve(); }); } diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index a1e76679f7..f515829b91 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -31,7 +31,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { } async removeLocation(id: string): Promise { - await this.database.removeLocation(id); + await this.database.transaction(tx => this.database.removeLocation(tx, id)); } async locations(): Promise { diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index f1723e3733..1802ea5e7d 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -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( diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 882995c142..75d1bfb64f 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -319,6 +319,21 @@ export class CommonDatabase implements Database { return toEntityResponse(rows[0]); } + async entityById( + txOpaque: unknown, + id: string, + ): Promise { + const tx = txOpaque as Knex.Transaction; + + const rows = await tx('entities').where({ id }).select(); + + if (rows.length !== 1) { + return undefined; + } + + return toEntityResponse(rows[0]); + } + async removeEntity(txOpaque: unknown, uid: string): Promise { const tx = txOpaque as Knex.Transaction; @@ -341,10 +356,10 @@ export class CommonDatabase implements Database { }); } - async removeLocation(id: string): Promise { - const result = await this.database('locations') - .where({ id }) - .del(); + async removeLocation(txOpaque: unknown, id: string): Promise { + const tx = txOpaque as Knex.Transaction; + + const result = await tx('locations').where({ id }).del(); if (!result) { throw new NotFoundError(`Found no location with ID ${id}`); diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 17da373ae9..078319d9cb 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -130,11 +130,13 @@ export type Database = { namespace?: string, ): Promise; + entityById(tx: unknown, id: string): Promise; + removeEntity(tx: unknown, uid: string): Promise; addLocation(location: Location): Promise; - removeLocation(id: string): Promise; + removeLocation(tx: unknown, id: string): Promise; location(id: string): Promise; diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 9ea59aab48..231c6c45d4 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -45,6 +45,36 @@ export class CatalogClient implements CatalogApi { } return undefined; } + async removeEntityByUid(uid: string): Promise { + 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 { + 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; + } async getEntities( filter?: Record, ): Promise { diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 1cfe5736fb..3076d6950b 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -24,10 +24,12 @@ export const catalogApiRef = createApiRef({ export interface CatalogApi { getLocationById(id: String): Promise; + removeLocationById(id: String): Promise; getEntities(filter?: Record): Promise; getEntityByName(name: string): Promise; addLocation(type: string, target: string): Promise; getLocationByEntity(entity: Entity): Promise; + removeEntityByUid(uid: string): Promise; } export type AddLocationResponse = { location: Location; entities: Entity[] }; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 029fad5474..f7b2addbd6 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -20,9 +20,30 @@ import CatalogTable from './CatalogTable'; import { Component } from '../../data/component'; const components: Component[] = [ - { name: 'component1', kind: 'Component', description: 'Placeholder' }, - { name: 'component2', kind: 'Component', description: 'Placeholder' }, - { name: 'component3', kind: 'Component', description: 'Placeholder' }, + { + name: 'component1', + kind: 'Component', + description: 'Placeholder', + metadata: { + name: 'component1', + }, + }, + { + name: 'component2', + kind: 'Component', + description: 'Placeholder', + metadata: { + name: 'component2', + }, + }, + { + name: 'component3', + kind: 'Component', + description: 'Placeholder', + metadata: { + name: 'component3', + }, + }, ]; describe('CatalogTable component', () => { @@ -48,7 +69,7 @@ describe('CatalogTable component', () => { ), ); const errorMessage = await rendered.findByText( - 'Error encountered while fetching components.', + 'Something went wrong here. Please contact #backstage for help.', ); expect(errorMessage).toBeInTheDocument(); }); diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx index 62e3d52c29..06b768d55f 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx +++ b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx @@ -15,24 +15,26 @@ */ import React from 'react'; import ComponentMetadataCard from './ComponentMetadataCard'; -import { Component } from '../../data/component'; import { render } from '@testing-library/react'; +import { Entity } from '../../../../../packages/catalog-model/src/entity/Entity'; describe('ComponentMetadataCard component', () => { it('should display component name if provided', async () => { - const testComponent: Component = { - name: 'test', + const testEntity: Entity = { + apiVersion: '', kind: 'Component', - description: 'Placeholder', + metadata: { + name: 'test', + }, }; const rendered = await render( - , + , ); expect(await rendered.findByText('test')).toBeInTheDocument(); }); it('should display loader when loading is set to true', async () => { const rendered = await render( - , + , ); expect(await rendered.findByRole('progressbar')).toBeInTheDocument(); }); diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx index 7059709992..8f504545e3 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx +++ b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx @@ -14,16 +14,16 @@ * limitations under the License. */ import React, { FC } from 'react'; -import { Component } from '../../data/component'; import { Progress, InfoCard, StructuredMetadataTable } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; type ComponentMetadataCardProps = { loading: boolean; - component: Component | undefined; + entity: Entity | undefined; }; const ComponentMetadataCard: FC = ({ loading, - component, + entity, }) => { if (loading) { return ( @@ -32,12 +32,12 @@ const ComponentMetadataCard: FC = ({ ); } - if (!component) { + if (!entity) { return null; } return ( - + ); }; diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx index f26c21b6ca..b7de5de01e 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx @@ -32,8 +32,7 @@ import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDi import { SentryIssuesWidget } from '@backstage/plugin-sentry'; import { Grid } from '@material-ui/core'; import { catalogApiRef } from '../..'; -import { entityToComponent } from '../../data/utils'; -import { Component } from '../../data/component'; +import { Entity } from '@backstage/catalog-model'; const REDIRECT_DELAY = 1000; @@ -53,14 +52,12 @@ const ComponentPage: FC = ({ match, history }) => { const [removingPending, setRemovingPending] = useState(false); const showRemovalDialog = () => setConfirmationDialogOpen(true); const hideRemovalDialog = () => setConfirmationDialogOpen(false); - const componentName = match.params.name; + const entityName = match.params.name; const errorApi = useApi(errorApiRef); const catalogApi = useApi(catalogApiRef); - const { value: component, error, loading } = useAsync(async () => { - const entity = await catalogApi.getEntityByName(match.params.name); - const location = await catalogApi.getLocationByEntity(entity); - return { ...entityToComponent(entity), location }; + const { value: entity, error, loading } = useAsync(async () => { + return await catalogApi.getEntityByName(match.params.name); }); useEffect(() => { @@ -72,17 +69,14 @@ const ComponentPage: FC = ({ match, history }) => { } }, [error, errorApi, history]); - if (componentName === '') { + if (entityName === '') { history.push('/catalog'); return null; } - const removeComponent = async () => { + const cleanUpAfterRemoval = async () => { setConfirmationDialogOpen(false); setRemovingPending(true); - // await componentFactory.removeComponentByName(componentName); - - await catalogApi; history.push('/'); }; @@ -117,16 +111,16 @@ const ComponentPage: FC = ({ match, history }) => { return ( // TODO: Switch theme and type props based on component type (website, library, ...) -
+
- {confirmationDialogOpen && component && ( + {confirmationDialogOpen && entity && ( )} @@ -135,7 +129,7 @@ const ComponentPage: FC = ({ match, history }) => { diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx index 2c9ccc3eea..a923382111 100644 --- a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx +++ b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model'; -import { Progress, useApi } from '@backstage/core'; +import { Progress, useApi, alertApiRef } from '@backstage/core'; import { Button, Dialog, @@ -32,32 +32,33 @@ import React, { FC } from 'react'; import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; import { catalogApiRef } from '../../api/types'; -import { Component } from '../../data/component'; type ComponentRemovalDialogProps = { onConfirm: () => any; onCancel: () => any; onClose: () => any; - component: Component; + entity: Entity; }; -function useColocatedEntities(component: Component): AsyncState { +function useColocatedEntities(entity: Entity): AsyncState { const catalogApi = useApi(catalogApiRef); return useAsync(async () => { - const myLocation = component.metadata.annotations?.[LOCATION_ANNOTATION]; + const myLocation = entity.metadata.annotations?.[LOCATION_ANNOTATION]; return myLocation ? await catalogApi.getEntities({ [LOCATION_ANNOTATION]: myLocation }) : []; - }, [catalogApi, component]); + }, [catalogApi, entity]); } const ComponentRemovalDialog: FC = ({ onConfirm, onCancel, onClose, - component, + entity, }) => { - const { value: entities, loading, error } = useColocatedEntities(component); + 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')); @@ -107,7 +108,20 @@ const ComponentRemovalDialog: FC = ({