feat(catalog): implement entities with location removal
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 entityById(
|
||||
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>;
|
||||
|
||||
entityById(tx: unknown, id: 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>;
|
||||
|
||||
|
||||
@@ -45,6 +45,36 @@ export class CatalogClient implements CatalogApi {
|
||||
}
|
||||
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;
|
||||
}
|
||||
async getEntities(
|
||||
filter?: Record<string, string>,
|
||||
): Promise<DescriptorEnvelope[]> {
|
||||
|
||||
@@ -24,10 +24,12 @@ export const catalogApiRef = createApiRef<CatalogApi>({
|
||||
|
||||
export interface CatalogApi {
|
||||
getLocationById(id: String): Promise<Location | undefined>;
|
||||
removeLocationById(id: String): Promise<void>;
|
||||
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
|
||||
getEntityByName(name: 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[] };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<ComponentMetadataCard loading={false} component={testComponent} />,
|
||||
<ComponentMetadataCard loading={false} 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 component={undefined} />,
|
||||
<ComponentMetadataCard loading entity={undefined} />,
|
||||
);
|
||||
expect(await rendered.findByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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<ComponentMetadataCardProps> = ({
|
||||
loading,
|
||||
component,
|
||||
entity,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -32,12 +32,12 @@ const ComponentMetadataCard: FC<ComponentMetadataCardProps> = ({
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
if (!component) {
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<InfoCard title="Metadata">
|
||||
<StructuredMetadataTable metadata={component} />
|
||||
<StructuredMetadataTable metadata={entity} />
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<ComponentPageProps> = ({ 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<ErrorApi>(errorApiRef);
|
||||
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value: component, error, loading } = useAsync<Component>(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<Entity>(async () => {
|
||||
return await catalogApi.getEntityByName(match.params.name);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,17 +69,14 @@ const ComponentPage: FC<ComponentPageProps> = ({ 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<ComponentPageProps> = ({ match, history }) => {
|
||||
return (
|
||||
// TODO: Switch theme and type props based on component type (website, library, ...)
|
||||
<Page theme={pageTheme.service}>
|
||||
<Header title={component?.name || 'Catalog'} type="Service">
|
||||
<Header title={entity?.metadata.name || 'Catalog'}>
|
||||
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
|
||||
</Header>
|
||||
<HeaderTabs tabs={tabs} />
|
||||
|
||||
{confirmationDialogOpen && component && (
|
||||
{confirmationDialogOpen && entity && (
|
||||
<ComponentRemovalDialog
|
||||
component={component}
|
||||
entity={entity}
|
||||
onClose={hideRemovalDialog}
|
||||
onConfirm={removeComponent}
|
||||
onConfirm={cleanUpAfterRemoval}
|
||||
onCancel={hideRemovalDialog}
|
||||
/>
|
||||
)}
|
||||
@@ -135,7 +129,7 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
<Grid item>
|
||||
<ComponentMetadataCard
|
||||
loading={loading || removingPending}
|
||||
component={component}
|
||||
entity={entity}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
|
||||
@@ -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<Entity[]> {
|
||||
function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
|
||||
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<ComponentRemovalDialogProps> = ({
|
||||
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<ComponentRemovalDialogProps> = ({
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!!(loading || error)}
|
||||
onClick={onConfirm}
|
||||
onClick={async () => {
|
||||
const uid = entity.metadata?.uid;
|
||||
if (uid) {
|
||||
try {
|
||||
await catalogApi.removeEntityByUid(uid);
|
||||
} catch (err) {
|
||||
alertApi.post({ message: err.message });
|
||||
}
|
||||
} else {
|
||||
alertApi.post({ message: `No entity with UID ${uid}` });
|
||||
}
|
||||
|
||||
onConfirm();
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
Unregister
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { EntityMeta, Location } from '@backstage/catalog-model';
|
||||
import { EntityMeta, LocationSpec } from '@backstage/catalog-model';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type Component = {
|
||||
@@ -21,5 +21,5 @@ export type Component = {
|
||||
kind: string;
|
||||
metadata: EntityMeta;
|
||||
description: ReactNode;
|
||||
location?: Location;
|
||||
location?: LocationSpec;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import React from 'react';
|
||||
import { Component } from './component';
|
||||
import {
|
||||
Entity,
|
||||
Location,
|
||||
LOCATION_ANNOTATION,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
@@ -30,7 +29,7 @@ const DescriptionWrapper = styled('span')({
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const createEditLink = (location: Location): string => {
|
||||
const createEditLink = (location: LocationSpec): string => {
|
||||
switch (location.type) {
|
||||
case 'github':
|
||||
return location.target.replace('/blob/', '/edit/');
|
||||
|
||||
-1
@@ -31,7 +31,6 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
getEntitiesByLocationId: jest.fn(),
|
||||
};
|
||||
|
||||
const setup = () => ({
|
||||
|
||||
Reference in New Issue
Block a user