diff --git a/.changeset/lucky-kids-cough.md b/.changeset/lucky-kids-cough.md new file mode 100644 index 0000000000..5ab1856e35 --- /dev/null +++ b/.changeset/lucky-kids-cough.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +--- + +- Fixes bug where after unregistering an entity you are redirected to `/`. +- Adds an optional external route `unregisterRedirect` to override this behaviour to another route. diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx index 68ce5fb497..25181db2c1 100644 --- a/packages/app/src/components/catalog/EntityPage.test.tsx +++ b/packages/app/src/components/catalog/EntityPage.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EntityLayout } from '@backstage/plugin-catalog'; +import { EntityLayout, catalogPlugin } from '@backstage/plugin-catalog'; import { EntityProvider, starredEntitiesApiRef, @@ -51,6 +51,7 @@ describe('EntityPage Test', () => { listWorkflowRuns: jest.fn().mockResolvedValue([]), }; const mockPermissionApi = new MockPermissionApi(); + const rootRouteRef = catalogPlugin.routes.catalogIndex; describe('cicdContent', () => { it('Should render GitHub Actions View', async () => { @@ -70,6 +71,11 @@ describe('EntityPage Test', () => { , + { + mountedRoutes: { + '/catalog': rootRouteRef, + }, + }, ); expect(rendered.getByText('ExampleComponent')).toBeInTheDocument(); diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 6647f8deaa..874d294cdc 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -122,6 +122,7 @@ export const catalogPlugin: BackstagePlugin< }, true >; + unregisterRedirect: ExternalRouteRef; } >; diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 3634405af8..c222c2d7b6 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -15,7 +15,11 @@ */ import { CatalogApi } from '@backstage/catalog-client'; -import { Entity } from '@backstage/catalog-model'; +import { + ANNOTATION_ORIGIN_LOCATION, + Entity, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; import { ApiProvider } from '@backstage/core-app-api'; import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; import { @@ -30,27 +34,30 @@ import { permissionApiRef } from '@backstage/plugin-permission-react'; import { MockPermissionApi, renderInTestApp, + TestApiProvider, TestApiRegistry, } from '@backstage/test-utils'; -import { act, fireEvent, screen } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { EntityLayout } from './EntityLayout'; - -const mockEntity = { - kind: 'MyKind', - metadata: { - name: 'my-entity', - }, -} as Entity; - -const mockApis = TestApiRegistry.from( - [catalogApiRef, {} as CatalogApi], - [alertApiRef, {} as AlertApi], - [starredEntitiesApiRef, new MockStarredEntitiesApi()], - [permissionApiRef, new MockPermissionApi()], -); +import { rootRouteRef, unregisterRedirectRouteRef } from '../../routes'; +import { Route, Routes } from 'react-router-dom'; describe('EntityLayout', () => { + const mockEntity = { + kind: 'MyKind', + metadata: { + name: 'my-entity', + }, + } as Entity; + + const mockApis = TestApiRegistry.from( + [catalogApiRef, {} as CatalogApi], + [alertApiRef, {} as AlertApi], + [starredEntitiesApiRef, new MockStarredEntitiesApi()], + [permissionApiRef, new MockPermissionApi()], + ); + it('renders simplest case', async () => { await renderInTestApp( @@ -65,6 +72,7 @@ describe('EntityLayout', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -97,6 +105,7 @@ describe('EntityLayout', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -120,6 +129,7 @@ describe('EntityLayout', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -146,6 +156,7 @@ describe('EntityLayout', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -178,6 +189,7 @@ describe('EntityLayout', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -225,6 +237,7 @@ describe('EntityLayout', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -253,6 +266,7 @@ describe('EntityLayout', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -265,3 +279,168 @@ describe('EntityLayout', () => { expect(linkParent?.tagName).toBe('P'); }); }); + +describe('EntityLayout - CleanUpAfterRemoval', () => { + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'n', + namespace: 'ns', + annotations: { + [ANNOTATION_ORIGIN_LOCATION]: 'url:http://example.com', + }, + }, + spec: { + owner: 'tools', + type: 'service', + }, + relations: [ + { + type: RELATION_OWNED_BY, + targetRef: 'group:default/tools', + }, + ], + }; + const getLocationByRef: jest.MockedFunction = + jest.fn(); + const getEntities: jest.MockedFunction = jest.fn(); + const removeEntityByUid: jest.MockedFunction< + CatalogApi['removeEntityByUid'] + > = jest.fn(); + const getEntityFacets: jest.MockedFunction = + jest.fn(); + getLocationByRef.mockResolvedValue(undefined); + getEntities.mockResolvedValue({ items: [{ ...entity }] }); + getEntityFacets.mockResolvedValue({ + facets: { + 'relations.ownedBy': [{ count: 1, value: 'group:default/tools' }], + }, + }); + + const alertApi: AlertApi = { + post() { + return undefined; + }, + alert$() { + throw new Error('not implemented'); + }, + }; + + it('redirects to externalRouteRef when unregisterRedirectRouteRef is bound', async () => { + await renderInTestApp( + + + + +
tabbed-test-content
+
+
+
+ + catalog-page

} /> + external-page

} /> +
+
, + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, + '/testRoute': unregisterRedirectRouteRef, + }, + }, + ); + + const menuButton = screen.queryAllByTestId('menu-button')[0]; + fireEvent.click(menuButton); + const listItemUnregister = screen.queryAllByRole('menuitem', { + name: /Unregister entity/i, + })[0]; + fireEvent.click(listItemUnregister); + await waitFor(() => { + const deleteEntityButton = screen.getByRole('button', { + name: /Delete Entity/i, + }); + act(() => { + fireEvent.click(deleteEntityButton); + }); + }); + + await waitFor(() => { + expect(screen.getByText('external-page')).toBeInTheDocument(); + }); + }); + + it('redirects to rootRouteRef when unregisterRedirectRouteRef is not bound', async () => { + await renderInTestApp( + + + + +
tabbed-test-content
+
+
+
+ + catalog-page

} /> + external-page

} /> +
+
, + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, + }, + }, + ); + + const menuButton = screen.queryAllByTestId('menu-button')[0]; + fireEvent.click(menuButton); + const listItemUnregister = screen.queryAllByRole('menuitem', { + name: /Unregister entity/i, + })[0]; + fireEvent.click(listItemUnregister); + await waitFor(() => { + const deleteEntityButton = screen.getByRole('button', { + name: /Delete Entity/i, + }); + act(() => { + fireEvent.click(deleteEntityButton); + }); + }); + + await waitFor(() => { + expect(screen.getByText('catalog-page')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index f9999f26e9..8e11f92599 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -33,6 +33,7 @@ import { attachComponentData, IconComponent, useElementFilter, + useRouteRef, useRouteRefParams, } from '@backstage/core-plugin-api'; import { @@ -50,6 +51,7 @@ import { Alert } from '@material-ui/lab'; import React, { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; +import { rootRouteRef, unregisterRedirectRouteRef } from '../../routes'; /** @public */ export type EntityLayoutRouteProps = { @@ -229,10 +231,15 @@ export const EntityLayout = (props: EntityLayoutProps) => { const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const [inspectionDialogOpen, setInspectionDialogOpen] = useState(false); const navigate = useNavigate(); + const catalogRoute = useRouteRef(rootRouteRef); + const unregisterRedirectRoute = useRouteRef(unregisterRedirectRouteRef); + const cleanUpAfterRemoval = async () => { setConfirmationDialogOpen(false); setInspectionDialogOpen(false); - navigate('/'); + navigate( + unregisterRedirectRoute ? unregisterRedirectRoute() : catalogRoute(), + ); }; // Make sure to close the dialog if the user clicks links in it that navigate diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index d310639fb1..f41fefe404 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -25,6 +25,7 @@ import { import { createComponentRouteRef, createFromTemplateRouteRef, + unregisterRedirectRouteRef, viewTechDocRouteRef, } from './routes'; import { @@ -89,6 +90,7 @@ export const catalogPlugin = createPlugin({ createComponent: createComponentRouteRef, viewTechDoc: viewTechDocRouteRef, createFromTemplate: createFromTemplateRouteRef, + unregisterRedirect: unregisterRedirectRouteRef, }, }); diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index ab70a3c551..dcc46c7988 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -36,6 +36,11 @@ export const createFromTemplateRouteRef = createExternalRouteRef({ params: ['namespace', 'templateName'], }); +export const unregisterRedirectRouteRef = createExternalRouteRef({ + id: 'catalog:unregister-redirect', + optional: true, +}); + export const rootRouteRef = createRouteRef({ id: 'catalog', }); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx index 6613aa5b6a..cd5801ac71 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -31,7 +31,7 @@ import { mockedCatalogApiSupportingGroups, } from '../../../../__testUtils__/catalogMocks'; import { permissionApiRef } from '@backstage/plugin-permission-react'; -import { EntityLayout } from '@backstage/plugin-catalog'; +import { EntityLayout, catalogPlugin } from '@backstage/plugin-catalog'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Observable } from '@backstage/types'; @@ -63,6 +63,8 @@ const mockedStarredEntitiesApi: Partial = { }, }; +const rootRouteRef = catalogPlugin.routes.catalogIndex; + describe('MemberTab Test', () => { const groupEntity: GroupEntity = { apiVersion: 'backstage.io/v1alpha1', @@ -122,6 +124,7 @@ describe('MemberTab Test', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -151,6 +154,7 @@ describe('MemberTab Test', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -179,6 +183,7 @@ describe('MemberTab Test', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -206,6 +211,7 @@ describe('MemberTab Test', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -231,6 +237,7 @@ describe('MemberTab Test', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -265,6 +272,7 @@ describe('MemberTab Test', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, ); @@ -299,6 +307,7 @@ describe('MemberTab Test', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef, + '/catalog': rootRouteRef, }, }, );