diff --git a/.changeset/friendly-shoes-compare.md b/.changeset/friendly-shoes-compare.md
new file mode 100644
index 0000000000..0b242511c0
--- /dev/null
+++ b/.changeset/friendly-shoes-compare.md
@@ -0,0 +1,9 @@
+---
+'@backstage/plugin-catalog': patch
+---
+
+Add `CatalogIndexPage` and `CatalogEntityPage`, two new extensions that replace the existing `Router` component.
+
+Add `EntityLayout` to replace `EntityPageLayout`, using children instead of an element property, and allowing for collection of all `RouteRef` mount points used within tabs.
+
+Add `EntitySwitch` to be used to select components based on entity data, along with accompanying `isKind`, `isNamespace`, and `isComponentType` filters.
diff --git a/plugins/catalog/src/components/CatalogEntityPage/CatalogEntityPage.tsx b/plugins/catalog/src/components/CatalogEntityPage/CatalogEntityPage.tsx
new file mode 100644
index 0000000000..e0a314a391
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogEntityPage/CatalogEntityPage.tsx
@@ -0,0 +1,27 @@
+/*
+ * 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 React from 'react';
+import { Outlet } from 'react-router';
+import { EntityProvider } from '../EntityProvider';
+
+export const CatalogEntityPage = () => {
+ return (
+
+
+
+ );
+};
diff --git a/plugins/catalog/src/components/CatalogEntityPage/index.ts b/plugins/catalog/src/components/CatalogEntityPage/index.ts
new file mode 100644
index 0000000000..627ca80e48
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogEntityPage/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export { CatalogEntityPage } from './CatalogEntityPage';
diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx
new file mode 100644
index 0000000000..5d1f010740
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx
@@ -0,0 +1,128 @@
+/*
+ * 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 React from 'react';
+import { EntityLayout } from './EntityLayout';
+import {
+ AlertApi,
+ alertApiRef,
+ ApiProvider,
+ ApiRegistry,
+} from '@backstage/core';
+import { renderInTestApp, withLogCollector } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import { act } from 'react-dom/test-utils';
+import { Routes, Route } from 'react-router';
+import { Entity } from '@backstage/catalog-model';
+import { EntityContext } from '../../hooks/useEntity';
+import { catalogApiRef } from '../../plugin';
+import { CatalogApi } from '@backstage/catalog-client';
+
+const mockEntityData = {
+ loading: false,
+ error: undefined,
+ entity: {
+ kind: 'MyKind',
+ metadata: {
+ name: 'my-entity',
+ },
+ } as Entity,
+};
+
+const mockApis = ApiRegistry.with(catalogApiRef, {} as CatalogApi).with(
+ alertApiRef,
+ {} as AlertApi,
+);
+
+describe('EntityLayout', () => {
+ it('renders simplest case', async () => {
+ const rendered = await renderInTestApp(
+
+
+
+
+
tabbed-test-content
+
+
+
+ ,
+ );
+
+ expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
+ });
+
+ it('throws if any other component is a child of TabbedLayout', async () => {
+ const { error } = await withLogCollector(async () => {
+ await expect(
+ renderInTestApp(
+
+
+
tabbed-test-content
+
+
This will cause app to throw
+ ,
+ ),
+ ).rejects.toThrow(/Child of EntityLayout must be an EntityLayout.Route/);
+ });
+
+ expect(error).toEqual([
+ expect.stringMatching(
+ /Child of EntityLayout must be an EntityLayout.Route/,
+ ),
+ expect.stringMatching(
+ /The above error occurred in the component/,
+ ),
+ ]);
+ });
+
+ it('navigates when user clicks different tab', async () => {
+ const rendered = await renderInTestApp(
+
+
+
+
+
+
tabbed-test-content
+
+
+
tabbed-test-content-2
+
+
+
+
+ }
+ />
+ ,
+ );
+
+ const secondTab = rendered.queryAllByRole('tab')[1];
+ act(() => {
+ fireEvent.click(secondTab);
+ });
+
+ expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
+
+ expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
new file mode 100644
index 0000000000..09b8d02c60
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
@@ -0,0 +1,192 @@
+/*
+ * 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 React, {
+ Children,
+ Fragment,
+ PropsWithChildren,
+ ReactNode,
+ isValidElement,
+ useContext,
+ useState,
+} from 'react';
+import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
+import {
+ attachComponentData,
+ Content,
+ Header,
+ HeaderLabel,
+ Page,
+ Progress,
+} from '@backstage/core';
+import { Box } from '@material-ui/core';
+import { Alert } from '@material-ui/lab';
+import { useNavigate } from 'react-router';
+import { EntityContext } from '../../hooks/useEntity';
+import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
+import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
+import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
+import { useEntityCompoundName } from '../useEntityCompoundName';
+import { TabbedLayout } from './TabbedLayout';
+
+type SubRoute = {
+ path: string;
+ title: string;
+ children: JSX.Element;
+};
+
+const Route: (props: SubRoute) => null = () => null;
+
+// This causes all mount points that are discovered within this route to use the path of the route itself
+attachComponentData(Route, 'core.gatherMountPoints', true);
+
+export function createSubRoutesFromChildren(children: ReactNode): SubRoute[] {
+ return Children.toArray(children).flatMap(child => {
+ if (!isValidElement(child)) {
+ return [];
+ }
+
+ if (child.type === Fragment) {
+ return createSubRoutesFromChildren(child.props.children);
+ }
+
+ if (child.type !== Route) {
+ throw new Error('Child of EntityLayout must be an EntityLayout.Route');
+ }
+
+ const { path, title, children } = child.props;
+ return [{ path, title, children }];
+ });
+}
+
+const EntityLayoutTitle = ({
+ entity,
+ title,
+}: {
+ title: string;
+ entity: Entity | undefined;
+}) => (
+
+ {title}
+ {entity && }
+
+);
+
+const headerProps = (
+ paramKind: string | undefined,
+ paramNamespace: string | undefined,
+ paramName: string | undefined,
+ entity: Entity | undefined,
+): { headerTitle: string; headerType: string } => {
+ const kind = paramKind ?? entity?.kind ?? '';
+ const namespace = paramNamespace ?? entity?.metadata.namespace ?? '';
+ const name = paramName ?? entity?.metadata.name ?? '';
+ return {
+ headerTitle: `${name}${
+ namespace && namespace !== ENTITY_DEFAULT_NAMESPACE
+ ? ` in ${namespace}`
+ : ''
+ }`,
+ headerType: (() => {
+ let t = kind.toLowerCase();
+ if (entity && entity.spec && 'type' in entity.spec) {
+ t += ' — ';
+ t += (entity.spec as { type: string }).type.toLowerCase();
+ }
+ return t;
+ })(),
+ };
+};
+
+/**
+ * EntityLayout is a compound component, which allows you to define a layout for
+ * entities using a sub-navigation mechanism.
+ *
+ * Consists of two parts: EntityLayout and EntityLayout.Route
+ *
+ * @example
+ * ```jsx
+ *
+ *
+ *
This is rendered under /example/anything-here route
+ *
+ *
+ * ```
+ */
+export const EntityLayout = ({ children }: PropsWithChildren<{}>) => {
+ const { kind, namespace, name } = useEntityCompoundName();
+ const { entity, loading, error } = useContext(EntityContext);
+
+ const routes = createSubRoutesFromChildren(children);
+ const { headerTitle, headerType } = headerProps(
+ kind,
+ namespace,
+ name,
+ entity,
+ );
+
+ const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
+ const navigate = useNavigate();
+ const cleanUpAfterRemoval = async () => {
+ setConfirmationDialogOpen(false);
+ navigate('/');
+ };
+
+ const showRemovalDialog = () => setConfirmationDialogOpen(true);
+
+ return (
+
+ }
+ pageTitleOverride={headerTitle}
+ type={headerType}
+ >
+ {/* TODO: fix after catalog page customization is added */}
+ {entity && kind !== 'user' && (
+ <>
+
+
+
+ >
+ )}
+
+
+ {loading && }
+
+ {entity && }
+
+ {error && (
+
+ {error.toString()}
+
+ )}
+ setConfirmationDialogOpen(false)}
+ />
+
+ );
+};
+
+EntityLayout.Route = Route;
diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx
new file mode 100644
index 0000000000..065265cea4
--- /dev/null
+++ b/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx
@@ -0,0 +1,150 @@
+/*
+ * 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 React from 'react';
+import { TabbedLayout } from './TabbedLayout';
+import { renderInTestApp } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import { act } from 'react-dom/test-utils';
+import { Routes, Route } from 'react-router';
+
+const testRoute1 = {
+ path: '',
+ title: 'tabbed-test-title',
+ children: