diff --git a/.changeset/wise-mugs-sell.md b/.changeset/wise-mugs-sell.md new file mode 100644 index 0000000000..35b333ba75 --- /dev/null +++ b/.changeset/wise-mugs-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Display warning when Entity has orphan annotation. diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index c1d892e061..f646992c97 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -40,6 +40,8 @@ import { isComponentType, isKind, EntityHasResourcesCard, + EntityOrphanWarning, + isOrphan, } from '@backstage/plugin-catalog'; import { EntityCircleCIContent, @@ -214,7 +216,15 @@ const errorsContent = ( const overviewContent = ( - + + + + + + + + + @@ -226,7 +236,7 @@ const overviewContent = ( - + @@ -260,7 +270,7 @@ const overviewContent = ( - + diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx new file mode 100644 index 0000000000..d9f266cce7 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2021 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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import { DeleteEntityDialog } from './DeleteEntityDialog'; +import { ORIGIN_LOCATION_ANNOTATION } from '@backstage/catalog-model'; +import { + AlertApi, + alertApiRef, + ApiProvider, + ApiRegistry, +} from '@backstage/core'; +import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { screen, waitFor } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('DeleteEntityDialog', () => { + const alertApi: jest.Mocked = { + post: jest.fn(), + alert$: jest.fn(), + }; + + const catalogClient: jest.Mocked = { + removeEntityByUid: jest.fn(), + } as any; + const apis = ApiRegistry.with(catalogApiRef, catalogClient).with( + alertApiRef, + alertApi, + ); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + uid: '123', + name: 'n', + namespace: 'ns', + annotations: { + [ORIGIN_LOCATION_ANNOTATION]: 'url:http://example.com', + }, + }, + spec: {}, + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('can cancel', async () => { + const onClose = jest.fn(); + + await renderInTestApp( + + {}} + entity={entity} + /> + , + ); + + userEvent.click(screen.getByText('Cancel')); + + await waitFor(() => { + expect(onClose).toBeCalled(); + }); + }); + + it('can delete', async () => { + const onConfirm = jest.fn(); + + await renderInTestApp( + + {}} + onConfirm={onConfirm} + entity={entity} + /> + , + ); + + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(catalogClient.removeEntityByUid).toBeCalledWith('123'); + expect(onConfirm).toBeCalled(); + }); + }); + + it('handles error', async () => { + const onConfirm = jest.fn(); + + await renderInTestApp( + + {}} + onConfirm={onConfirm} + entity={entity} + /> + , + ); + + catalogClient.removeEntityByUid.mockRejectedValue(new Error('no no no')); + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(catalogClient.removeEntityByUid).toBeCalledWith('123'); + expect(alertApi.post).toBeCalledWith({ message: 'no no no' }); + }); + }); +}); diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.tsx new file mode 100644 index 0000000000..26b6b29650 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2021 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 { alertApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Button, Dialog, DialogActions, DialogTitle } from '@material-ui/core'; +import React, { useState } from 'react'; + +type Props = { + open: boolean; + onClose: () => any; + onConfirm: () => any; + entity: Entity; +}; + +export const DeleteEntityDialog = ({ + open, + onClose, + onConfirm, + entity, +}: Props) => { + const [busy, setBusy] = useState(false); + const catalogApi = useApi(catalogApiRef); + const alertApi = useApi(alertApiRef); + + const onDelete = async () => { + setBusy(true); + try { + const uid = entity.metadata.uid; + await catalogApi.removeEntityByUid(uid!); + onConfirm(); + } catch (err) { + alertApi.post({ message: err.message }); + } finally { + setBusy(false); + } + }; + + return ( + + + Are you sure you want to delete this entity? + + + + + + + ); +}; diff --git a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx new file mode 100644 index 0000000000..0b3b8de504 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { ApiProvider, ApiRegistry } from '@backstage/core'; + +import { + CatalogApi, + catalogApiRef, + catalogRouteRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { EntityOrphanWarning } from './EntityOrphanWarning'; + +describe('', () => { + const catalogClient: jest.Mocked = { + removeEntityByUid: jest.fn(), + } as any; + const apis = ApiRegistry.with(catalogApiRef, catalogClient); + + it('renders EntityOrphanWarning if the entity is orphan', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + description: 'This is the description', + annotations: { 'backstage.io/orphan': 'true' }, + }, + + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + const { getByText } = await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/create': catalogRouteRef, + }, + }, + ); + expect( + getByText( + 'This entity is not referenced by any location and is therefore not receiving updates. Click here to delete.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.tsx b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.tsx new file mode 100644 index 0000000000..070c36c56c --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2021 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 { useRouteRef } from '@backstage/core'; +import { catalogRouteRef, useEntity } from '@backstage/plugin-catalog-react'; +import { Alert } from '@material-ui/lab'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { DeleteEntityDialog } from './DeleteEntityDialog'; + +export const isOrphan = (entity: Entity) => + entity?.metadata?.annotations?.['backstage.io/orphan'] === 'true'; + +/** + * Displays a warning alert if the entity is marked as orphan with the ability to delete said entity. + */ +export const EntityOrphanWarning = () => { + const navigate = useNavigate(); + const catalogLink = useRouteRef(catalogRouteRef); + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const { entity } = useEntity(); + + const cleanUpAfterRemoval = async () => { + setConfirmationDialogOpen(false); + navigate(catalogLink()); + }; + + return ( + <> + setConfirmationDialogOpen(true)}> + This entity is not referenced by any location and is therefore not + receiving updates. Click here to delete. + + setConfirmationDialogOpen(false)} + /> + + ); +}; diff --git a/plugins/catalog/src/components/EntityOrphanWarning/index.ts b/plugins/catalog/src/components/EntityOrphanWarning/index.ts new file mode 100644 index 0000000000..b64f8c1232 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 { EntityOrphanWarning, isOrphan } from './EntityOrphanWarning'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 2e74219718..f7506b534a 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -35,3 +35,4 @@ export { EntityLinksCard, EntitySystemDiagramCard, } from './plugin'; +export * from './components/EntityOrphanWarning';