diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx
new file mode 100644
index 0000000000..3eccafbf7a
--- /dev/null
+++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.test.tsx
@@ -0,0 +1,183 @@
+/*
+ * 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 { Tabbed } from './Tabbed';
+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';
+
+describe('Tabbed layout', () => {
+ 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('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();
+ });
+
+ describe('correctly delegates nested links', () => {
+ const renderRoute = (route: string) =>
+ renderInTestApp(
+
+
+ tabbed-test-content}
+ />
+
+ tabbed-test-content-2
+
+ tabbed-test-nested-content-2}
+ />
+
+
+ }
+ />
+
+ }
+ />
+ ,
+ { routeEntries: [route] },
+ );
+
+ it('works for nested content', async () => {
+ const rendered = await renderRoute('/some-other-path/nested');
+
+ expect(
+ rendered.queryByText('tabbed-test-content'),
+ ).not.toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ expect(
+ rendered.queryByText('tabbed-test-nested-content-2'),
+ ).toBeInTheDocument();
+ });
+
+ it('works for non-nested content', async () => {
+ const rendered = await renderRoute('/some-other-path/');
+
+ expect(
+ rendered.queryByText('tabbed-test-content'),
+ ).not.toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ expect(
+ rendered.queryByText('tabbed-test-nested-content-2'),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows only one tab contents at a time', async () => {
+ const rendered = await renderInTestApp(
+
+ tabbed-test-content}
+ />
+ tabbed-test-content-2}
+ />
+ ,
+ { routeEntries: ['/some-other-path'] },
+ );
+
+ 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();
+ });
+
+ it('redirects to the top level when no route is matching the url', async () => {
+ const rendered = await renderInTestApp(
+
+ tabbed-test-content}
+ />
+ tabbed-test-content-2}
+ />
+ ,
+ { routeEntries: ['/non-existing-path'] },
+ );
+
+ expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
+ expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
+
+ expect(
+ rendered.queryByText('tabbed-test-content-2'),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx
index 6e32dd1c80..6f3e5e1fcf 100644
--- a/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx
+++ b/plugins/catalog/src/components/EntityPageLayout/Tabbed/Tabbed.tsx
@@ -34,6 +34,23 @@ const getSelectedIndex = (matchedRoute: RouteMatch, tabs: Tab[]) => {
return ~tabIndex ? tabIndex : 0;
};
+/**
+ * Compound component, which allows you to define layout
+ * for EntityPage using Tabs as a subnavigation mechanism
+ * Constists of 2 parts: Tabbed.Layout and Tabbed.Content.
+ * Takes care of: tabs, routes, document titles, spacing around content
+ *
+ * @example
+ * ```jsx
+ *
+ * This is rendered under /example/anything-here route}
+ * />
+ *
+ * ```
+ */
export const Tabbed = {
Layout: ({ children }: { children: React.ReactNode }) => {
const routes: PartialRouteObject[] = [];
@@ -73,7 +90,7 @@ export const Tabbed = {
const title = currentTab.label;
const onTabChange = (index: number) =>
- navigate(tabs[index].id.slice(1, -2));
+ navigate(tabs[index].id.replace(/\/\*$/g, ''));
const currentRouteElement = useRoutes(routes);