From 54c7d02f7b3ed6ccecf8fec7301ae9ec5beebb0c Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Fri, 29 Jan 2021 14:21:10 +0100 Subject: [PATCH] Introduce TabbedLayout for creating tabs that are routed --- .changeset/thirty-cobras-tickle.md | 15 +++ .../TabbedLayout/RoutedTabs.test.tsx | 18 ++-- .../components/TabbedLayout/RoutedTabs.tsx | 10 +- .../TabbedLayout/TabbedLayout.stories.tsx | 44 +++++++++ .../TabbedLayout/TabbedLayout.test.tsx | 94 +++++++++++++++++++ .../components/TabbedLayout/TabbedLayout.tsx | 87 +++++++++++++++++ .../core/src/components/TabbedLayout/index.ts | 16 ++++ .../src/components/TabbedLayout}/types.ts | 0 packages/core/src/components/index.ts | 7 +- .../components/EntityLayout/EntityLayout.tsx | 37 +------- plugins/explore/package.json | 4 +- .../src/components/DomainCard/DomainCard.tsx | 2 +- .../DomainExplorerContent.tsx | 2 +- .../components/ExplorePage/ExploreTabs.tsx | 41 +++----- 14 files changed, 294 insertions(+), 83 deletions(-) create mode 100644 .changeset/thirty-cobras-tickle.md rename plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx => packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx (92%) rename plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx => packages/core/src/components/TabbedLayout/RoutedTabs.tsx (89%) create mode 100644 packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx create mode 100644 packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx create mode 100644 packages/core/src/components/TabbedLayout/TabbedLayout.tsx create mode 100644 packages/core/src/components/TabbedLayout/index.ts rename {plugins/catalog/src/components/EntityLayout => packages/core/src/components/TabbedLayout}/types.ts (100%) 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 = () => ( + + + + + + + + +);