diff --git a/.changeset/thirty-cobras-tickle.md b/.changeset/thirty-cobras-tickle.md
new file mode 100644
index 0000000000..cf43318658
--- /dev/null
+++ b/.changeset/thirty-cobras-tickle.md
@@ -0,0 +1,15 @@
+---
+'@backstage/core': patch
+'@backstage/plugin-catalog': patch
+'@backstage/plugin-explore': patch
+---
+
+Introduce `TabbedLayout` for creating tabs that are routed.
+
+```typescript
+
+
+ This is rendered under /example/anything-here route
+
+
+```
diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx
similarity index 92%
rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx
rename to packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx
index 065265cea4..549aa55eff 100644
--- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx
+++ b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx
@@ -13,12 +13,12 @@
* 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 React from 'react';
import { act } from 'react-dom/test-utils';
-import { Routes, Route } from 'react-router';
+import { Route, Routes } from 'react-router';
+import { RoutedTabs } from './RoutedTabs';
const testRoute1 = {
path: '',
@@ -31,10 +31,10 @@ const testRoute2 = {
children:
tabbed-test-content-2
,
};
-describe('TabbedLayout', () => {
+describe('RoutedTabs', () => {
it('renders simplest case', async () => {
const rendered = await renderInTestApp(
- ,
+ ,
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
@@ -46,7 +46,7 @@ describe('TabbedLayout', () => {
}
+ element={}
/>
,
);
@@ -70,7 +70,7 @@ describe('TabbedLayout', () => {
{
it('shows only one tab contents at a time', async () => {
const rendered = await renderInTestApp(
- ,
+ ,
{ routeEntries: ['/some-other-path'] },
);
@@ -135,7 +135,7 @@ describe('TabbedLayout', () => {
it('redirects to the top level when no route is matching the url', async () => {
const rendered = await renderInTestApp(
- ,
+ ,
{ routeEntries: ['/non-existing-path'] },
);
diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx
similarity index 89%
rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx
rename to packages/core/src/components/TabbedLayout/RoutedTabs.tsx
index a2209ba492..ffeeeb26ad 100644
--- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx
+++ b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import React, { useMemo } from 'react';
-import { useParams, useNavigate, matchRoutes, useRoutes } from 'react-router';
-import { HeaderTabs, Content as LayoutContent } from '@backstage/core';
import { Helmet } from 'react-helmet';
+import { matchRoutes, useNavigate, useParams, useRoutes } from 'react-router';
+import { Content, HeaderTabs } from '../../layout';
import { SubRoute } from './types';
export function useSelectedSubRoute(
@@ -44,7 +44,7 @@ export function useSelectedSubRoute(
};
}
-export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => {
+export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => {
const navigate = useNavigate();
const { index, route, element } = useSelectedSubRoute(routes);
const headerTabs = useMemo(
@@ -66,10 +66,10 @@ export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => {
selectedIndex={index}
onChange={onTabChange}
/>
-
+
{element}
-
+
>
);
};
diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx
new file mode 100644
index 0000000000..bf6175a9a2
--- /dev/null
+++ b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx
@@ -0,0 +1,44 @@
+/*
+ * 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, { PropsWithChildren } from 'react';
+import { MemoryRouter, Route, Routes } from 'react-router';
+import { TabbedLayout } from './TabbedLayout';
+
+export default {
+ title: 'Navigation/TabbedLayout',
+ component: TabbedLayout,
+};
+
+const Wrapper = ({ children }: PropsWithChildren<{}>) => (
+
+
+ {children}>} />
+
+
+);
+
+export const Default = () => (
+
+
+
+ tabbed-test-content
+
+
+ tabbed-test-content-2
+
+
+
+);
diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx
new file mode 100644
index 0000000000..77230ab6cd
--- /dev/null
+++ b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx
@@ -0,0 +1,94 @@
+/*
+ * 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 { renderInTestApp, withLogCollector } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { Route, Routes } from 'react-router';
+import { TabbedLayout } from './TabbedLayout';
+
+describe('TabbedLayout', () => {
+ it('renders simplest case', async () => {
+ const { getByText } = await renderInTestApp(
+
+
+ tabbed-test-content
+
+ ,
+ );
+
+ expect(getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(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 TabbedLayout must be an TabbedLayout.Route/);
+ });
+
+ expect(error).toEqual([
+ expect.stringMatching(
+ /Child of TabbedLayout must be an TabbedLayout.Route/,
+ ),
+ expect.stringMatching(
+ /The above error occurred in the component/,
+ ),
+ ]);
+ });
+
+ it('navigates when user clicks different tab', async () => {
+ const { getByText, queryByText, queryAllByRole } = await renderInTestApp(
+
+
+
+ tabbed-test-content
+
+
+ tabbed-test-content-2
+
+
+ }
+ />
+ ,
+ );
+
+ const secondTab = queryAllByRole('tab')[1];
+ act(() => {
+ fireEvent.click(secondTab);
+ });
+
+ expect(getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(queryByText('tabbed-test-content')).not.toBeInTheDocument();
+
+ expect(getByText('tabbed-test-title-2')).toBeInTheDocument();
+ expect(queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx
new file mode 100644
index 0000000000..07ed444859
--- /dev/null
+++ b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx
@@ -0,0 +1,87 @@
+/*
+ * 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 { attachComponentData } from '@backstage/core-api';
+import React, {
+ Children,
+ Fragment,
+ isValidElement,
+ PropsWithChildren,
+ ReactNode,
+} from 'react';
+import { RoutedTabs } from './RoutedTabs';
+
+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[] {
+ // Directly comparing child.type with Route will not work with
+ // react-hot-loader for example in storybook
+ // https://github.com/gaearon/react-hot-loader/issues/304
+ const routeType = (
+
+
+
+ ).type;
+
+ return Children.toArray(children).flatMap(child => {
+ if (!isValidElement(child)) {
+ return [];
+ }
+
+ if (child.type === Fragment) {
+ return createSubRoutesFromChildren(child.props.children);
+ }
+
+ if (child.type !== routeType) {
+ throw new Error('Child of TabbedLayout must be an TabbedLayout.Route');
+ }
+
+ const { path, title, children } = child.props;
+ return [{ path, title, children }];
+ });
+}
+
+/**
+ * TabbedLayout is a compound component, which allows you to define a layout for
+ * pages using a sub-navigation mechanism.
+ *
+ * Consists of two parts: TabbedLayout and TabbedLayout.Route
+ *
+ * @example
+ * ```jsx
+ *
+ *
+ * This is rendered under /example/anything-here route
+ *
+ *
+ * ```
+ */
+export const TabbedLayout = ({ children }: PropsWithChildren<{}>) => {
+ const routes = createSubRoutesFromChildren(children);
+
+ return ;
+};
+
+TabbedLayout.Route = Route;
diff --git a/packages/core/src/components/TabbedLayout/index.ts b/packages/core/src/components/TabbedLayout/index.ts
new file mode 100644
index 0000000000..744b56959e
--- /dev/null
+++ b/packages/core/src/components/TabbedLayout/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 { TabbedLayout } from './TabbedLayout';
diff --git a/plugins/catalog/src/components/EntityLayout/types.ts b/packages/core/src/components/TabbedLayout/types.ts
similarity index 100%
rename from plugins/catalog/src/components/EntityLayout/types.ts
rename to packages/core/src/components/TabbedLayout/types.ts
diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts
index 85ebba7a58..77eba9759a 100644
--- a/packages/core/src/components/index.ts
+++ b/packages/core/src/components/index.ts
@@ -21,10 +21,13 @@ export * from './CodeSnippet';
export * from './CopyTextButton';
export * from './DependencyGraph';
export * from './DismissableBanner';
+export * from './EmptyState';
export * from './FeatureDiscovery';
+export * from './HeaderIconLinkRow';
export * from './HorizontalScrollGrid';
export * from './Lifecycle';
export * from './Link';
+export * from './MarkdownContent';
export * from './OAuthRequestDialog';
export * from './Progress';
export * from './ProgressBars';
@@ -32,10 +35,8 @@ export * from './SimpleStepper';
export * from './Status';
export * from './StructuredMetadataTable';
export * from './SupportButton';
+export * from './TabbedLayout';
export * from './Table';
export * from './Tabs';
export * from './TrendLine';
export * from './WarningPanel';
-export * from './EmptyState';
-export * from './MarkdownContent';
-export * from './HeaderIconLinkRow';
diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
index 4f7cb09cb6..597a389097 100644
--- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
+++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
@@ -16,12 +16,12 @@
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
- attachComponentData,
Content,
Header,
HeaderLabel,
Page,
Progress,
+ TabbedLayout,
} from '@backstage/core';
import {
EntityContext,
@@ -29,12 +29,9 @@ import {
} from '@backstage/plugin-catalog-react';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
-import React, {
- Children,
- Fragment,
- isValidElement,
+import {
+ default as React,
PropsWithChildren,
- ReactNode,
useContext,
useState,
} from 'react';
@@ -42,7 +39,6 @@ import { useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
-import { TabbedLayout } from './TabbedLayout';
type SubRoute = {
path: string;
@@ -50,29 +46,7 @@ type SubRoute = {
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 Route = (props: SubRoute) => ;
const EntityLayoutTitle = ({
entity,
@@ -132,7 +106,6 @@ 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,
@@ -174,7 +147,7 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => {
{loading && }
- {entity && }
+ {entity && {children}}
{error && (
diff --git a/plugins/explore/package.json b/plugins/explore/package.json
index 19a865d765..056d82eb73 100644
--- a/plugins/explore/package.json
+++ b/plugins/explore/package.json
@@ -32,7 +32,7 @@
"dependencies": {
"@backstage/catalog-model": "^0.7.0",
"@backstage/core": "^0.5.0",
- "@backstage/plugin-catalog": "^0.2.12",
+ "@backstage/plugin-catalog-react": "^0.0.1",
"@backstage/plugin-explore-react": "^0.0.1",
"@backstage/theme": "^0.2.2",
"@material-ui/core": "^4.11.0",
@@ -59,4 +59,4 @@
"files": [
"dist"
]
-}
+}
\ No newline at end of file
diff --git a/plugins/explore/src/components/DomainCard/DomainCard.tsx b/plugins/explore/src/components/DomainCard/DomainCard.tsx
index 78938ce641..366d878318 100644
--- a/plugins/explore/src/components/DomainCard/DomainCard.tsx
+++ b/plugins/explore/src/components/DomainCard/DomainCard.tsx
@@ -15,7 +15,7 @@
*/
import { DomainEntity } from '@backstage/catalog-model';
import { ItemCard } from '@backstage/core';
-import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
+import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog-react';
import React from 'react';
import { generatePath } from 'react-router-dom';
diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx
index 44478e5b71..ca00c525c9 100644
--- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx
+++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx
@@ -22,7 +22,7 @@ import {
SupportButton,
useApi,
} from '@backstage/core';
-import { catalogApiRef } from '@backstage/plugin-catalog';
+import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Button } from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
diff --git a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx
index ef7b64ebe2..0cf28f9f1c 100644
--- a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx
+++ b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx
@@ -13,37 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { Tabs } from '@backstage/core';
-import { makeStyles } from '@material-ui/core';
+import { TabbedLayout } from '@backstage/core';
import React from 'react';
import { DomainExplorerContent } from '../DomainExplorerContent';
import { ToolExplorerContent } from '../ToolExplorerContent';
-// TODO: Support sub routes for these tabs in the future
-
-const useStyles = makeStyles({
- layout: {
- gridArea: 'pageContent',
- },
-});
-
-export const ExploreTabs = () => {
- const classes = useStyles();
-
- return (
-
- ,
- },
- {
- label: `Tools`,
- content: ,
- },
- ]}
- />
-
- );
-};
+export const ExploreTabs = () => (
+
+
+
+
+
+
+
+
+);