Introduce TabbedLayout for creating tabs that are routed

This commit is contained in:
Oliver Sand
2021-01-29 14:21:10 +01:00
parent 9b78fb4bb6
commit 54c7d02f7b
14 changed files with 294 additions and 83 deletions
@@ -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 { renderInTestApp } 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 { RoutedTabs } from './RoutedTabs';
const testRoute1 = {
path: '',
title: 'tabbed-test-title',
children: <div>tabbed-test-content</div>,
};
const testRoute2 = {
title: 'tabbed-test-title-2',
path: '/some-other-path',
children: <div>tabbed-test-content-2</div>,
};
describe('RoutedTabs', () => {
it('renders simplest case', async () => {
const rendered = await renderInTestApp(
<RoutedTabs routes={[testRoute1]} />,
);
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(
<Routes>
<Route
path="/*"
element={<RoutedTabs routes={[testRoute1, testRoute2]} />}
/>
</Routes>,
);
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(
<Routes>
<Route
path="/*"
element={
<RoutedTabs
routes={[
testRoute1,
{
...testRoute2,
children: (
<div>
tabbed-test-content-2
<Routes>
<Route
path="/nested"
element={<div>tabbed-test-nested-content-2</div>}
/>
</Routes>
</div>
),
},
]}
/>
}
/>
</Routes>,
{ 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(
<RoutedTabs routes={[testRoute1, testRoute2]} />,
{ 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(
<RoutedTabs routes={[testRoute1, testRoute2]} />,
{ 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();
});
});
@@ -0,0 +1,75 @@
/*
* 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, { useMemo } from 'react';
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(
subRoutes: SubRoute[],
): { index: number; route: SubRoute; element: JSX.Element } {
const params = useParams();
const routes = subRoutes.map(({ path, children }) => ({
caseSensitive: false,
path: `${path}/*`,
element: children,
}));
const element = useRoutes(routes) ?? subRoutes[0].children;
const [matchedRoute] = matchRoutes(routes, `/${params['*']}`) ?? [];
const foundIndex = matchedRoute
? subRoutes.findIndex(t => `${t.path}/*` === matchedRoute.route.path)
: 0;
return {
index: foundIndex === -1 ? 0 : foundIndex,
element,
route: subRoutes[foundIndex] ?? subRoutes[0],
};
}
export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => {
const navigate = useNavigate();
const { index, route, element } = useSelectedSubRoute(routes);
const headerTabs = useMemo(
() => routes.map(t => ({ id: t.path, label: t.title })),
[routes],
);
const onTabChange = (index: number) =>
// Remove trailing /*
// And remove leading / for relative navigation
// Note! route resolves relative to the position in the React tree,
// not relative to current location
navigate(routes[index].path.replace(/\/\*$/, '').replace(/^\//, ''));
return (
<>
<HeaderTabs
tabs={headerTabs}
selectedIndex={index}
onChange={onTabChange}
/>
<Content>
<Helmet title={route.title} />
{element}
</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';
@@ -0,0 +1,21 @@
/*
* 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 type SubRoute = {
path: string;
title: string;
children: JSX.Element;
};
+4 -3
View File
@@ -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';