Introduce TabbedLayout for creating tabs that are routed
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
'@backstage/plugin-catalog': patch
|
||||
'@backstage/plugin-explore': patch
|
||||
---
|
||||
|
||||
Introduce `TabbedLayout` for creating tabs that are routed.
|
||||
|
||||
```typescript
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="/example" title="Example tab">
|
||||
<div>This is rendered under /example/anything-here route</div>
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
```
|
||||
+9
-9
@@ -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: <div>tabbed-test-content-2</div>,
|
||||
};
|
||||
|
||||
describe('TabbedLayout', () => {
|
||||
describe('RoutedTabs', () => {
|
||||
it('renders simplest case', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<TabbedLayout routes={[testRoute1]} />,
|
||||
<RoutedTabs routes={[testRoute1]} />,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
|
||||
@@ -46,7 +46,7 @@ describe('TabbedLayout', () => {
|
||||
<Routes>
|
||||
<Route
|
||||
path="/*"
|
||||
element={<TabbedLayout routes={[testRoute1, testRoute2]} />}
|
||||
element={<RoutedTabs routes={[testRoute1, testRoute2]} />}
|
||||
/>
|
||||
</Routes>,
|
||||
);
|
||||
@@ -70,7 +70,7 @@ describe('TabbedLayout', () => {
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<TabbedLayout
|
||||
<RoutedTabs
|
||||
routes={[
|
||||
testRoute1,
|
||||
{
|
||||
@@ -122,7 +122,7 @@ describe('TabbedLayout', () => {
|
||||
|
||||
it('shows only one tab contents at a time', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<TabbedLayout routes={[testRoute1, testRoute2]} />,
|
||||
<RoutedTabs routes={[testRoute1, testRoute2]} />,
|
||||
{ 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(
|
||||
<TabbedLayout routes={[testRoute1, testRoute2]} />,
|
||||
<RoutedTabs routes={[testRoute1, testRoute2]} />,
|
||||
{ routeEntries: ['/non-existing-path'] },
|
||||
);
|
||||
|
||||
+5
-5
@@ -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}
|
||||
/>
|
||||
<LayoutContent>
|
||||
<Content>
|
||||
<Helmet title={route.title} />
|
||||
{element}
|
||||
</LayoutContent>
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<{}>) => (
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/*" element={<>{children}</>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
export const Default = () => (
|
||||
<Wrapper>
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="/" title="tabbed-test-title">
|
||||
<div>tabbed-test-content</div>
|
||||
</TabbedLayout.Route>
|
||||
<TabbedLayout.Route path="/some-other-path" title="tabbed-test-title-2">
|
||||
<div>tabbed-test-content-2</div>
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -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(
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="/" title="tabbed-test-title">
|
||||
<div>tabbed-test-content</div>
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="/" title="tabbed-test-title">
|
||||
<div>tabbed-test-content</div>
|
||||
</TabbedLayout.Route>
|
||||
<div>This will cause app to throw</div>
|
||||
</TabbedLayout>,
|
||||
),
|
||||
).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 <TabbedLayout> component/,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it('navigates when user clicks different tab', async () => {
|
||||
const { getByText, queryByText, queryAllByRole } = await renderInTestApp(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="/" title="tabbed-test-title">
|
||||
<div>tabbed-test-content</div>
|
||||
</TabbedLayout.Route>
|
||||
<TabbedLayout.Route
|
||||
path="/some-other-path"
|
||||
title="tabbed-test-title-2"
|
||||
>
|
||||
<div>tabbed-test-content-2</div>
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
<Route path="" title="">
|
||||
<div />
|
||||
</Route>
|
||||
).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
|
||||
* <TabbedLayout>
|
||||
* <TabbedLayout.Route path="/example" title="Example tab">
|
||||
* <div>This is rendered under /example/anything-here route</div>
|
||||
* </TabbedLayout.Route>
|
||||
* </TabbedLayout>
|
||||
* ```
|
||||
*/
|
||||
export const TabbedLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
const routes = createSubRoutesFromChildren(children);
|
||||
|
||||
return <RoutedTabs routes={routes} />;
|
||||
};
|
||||
|
||||
TabbedLayout.Route = Route;
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => <TabbedLayout.Route {...props} />;
|
||||
|
||||
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 && <Progress />}
|
||||
|
||||
{entity && <TabbedLayout routes={routes} />}
|
||||
{entity && <TabbedLayout>{children}</TabbedLayout>}
|
||||
|
||||
{error && (
|
||||
<Content>
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
<div className={classes.layout}>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
label: `Domains`,
|
||||
content: <DomainExplorerContent />,
|
||||
},
|
||||
{
|
||||
label: `Tools`,
|
||||
content: <ToolExplorerContent />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ExploreTabs = () => (
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="domains" title="Domains">
|
||||
<DomainExplorerContent />
|
||||
</TabbedLayout.Route>
|
||||
<TabbedLayout.Route path="tools" title="Tools">
|
||||
<ToolExplorerContent />
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user