diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.beta.test.tsx similarity index 100% rename from packages/core-app-api/src/routing/FlatRoutes.test.tsx rename to packages/core-app-api/src/routing/FlatRoutes.beta.test.tsx diff --git a/packages/core-app-api/src/routing/FlatRoutes.compat.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.compat.test.tsx new file mode 100644 index 0000000000..c5b65c1461 --- /dev/null +++ b/packages/core-app-api/src/routing/FlatRoutes.compat.test.tsx @@ -0,0 +1,155 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { render, RenderResult } from '@testing-library/react'; +import React, { ReactNode } from 'react'; +import { LocalStorageFeatureFlags } from '../apis'; +import { featureFlagsApiRef } from '@backstage/core-plugin-api'; +import { AppContext } from '../app'; +import { AppContextProvider } from '../app/AppContext'; +import { TestApiProvider } from '@backstage/test-utils'; + +describe.each(['beta', 'stable'])('FlatRoutes %s', rrVersion => { + beforeAll(() => { + jest.doMock('react', () => React); + jest.doMock('react-router', () => + rrVersion === 'beta' + ? jest.requireActual('react-router') + : jest.requireActual('react-router-stable'), + ); + jest.doMock('react-router-dom', () => + rrVersion === 'beta' + ? jest.requireActual('react-router-dom') + : jest.requireActual('react-router-dom-stable'), + ); + }); + + afterAll(() => { + jest.resetModules(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); + + function requireDeps() { + return { + ...(require('./FlatRoutes') as typeof import('./FlatRoutes')), + ...(require('react-router-dom') as typeof import('react-router-dom')), + }; + } + + function makeRouteRenderer(node: ReactNode) { + const { MemoryRouter } = requireDeps(); + let rendered: RenderResult | undefined = undefined; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + + return (path: string) => { + const content = ( + + ({ + NotFoundErrorPage: () => <>Not Found, + }), + } as unknown as AppContext + } + > + + + + ); + if (rendered) { + rendered.unmount(); + rendered.rerender(content); + } else { + rendered = render(content); + } + return rendered; + }; + } + + it('renders some routes', () => { + const { Route, FlatRoutes } = requireDeps(); + const renderRoute = makeRouteRenderer( + + a} /> + b} /> + , + ); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('b')).toBeInTheDocument(); + expect(renderRoute('/c').getByText('Not Found')).toBeInTheDocument(); + expect(renderRoute('/b').queryByText('Not Found')).not.toBeInTheDocument(); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + }); + + it('is not sensitive to ordering and overlapping routes', () => { + const { Route, FlatRoutes } = requireDeps(); + // The '/*' suffixes here are intentional and will be ignored by FlatRoutes + const routes = ( + <> + a-1} /> + a} /> + a-2} /> + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/a-1').getByText('a-1')).toBeInTheDocument(); + expect(renderRoute('/a-2').getByText('a-2')).toBeInTheDocument(); + }); + + it('renders children straight as outlets', () => { + const { Route, useOutlet, FlatRoutes } = requireDeps(); + const MyPage = () => { + return <>Outlet: {useOutlet()}; + }; + + const routes = ( + <> + }> + a + + }> + a-b + + }> + b + + }>c + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('Outlet: a')).toBeInTheDocument(); + expect(renderRoute('/a/b').getByText('Outlet: a-b')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('Outlet: b')).toBeInTheDocument(); + expect(renderRoute('/').getByText('Outlet: c')).toBeInTheDocument(); + expect( + renderRoute('/not-found').queryByText('Outlet: c'), + ).not.toBeInTheDocument(); + expect( + renderRoute('/not-found').getByText('Not Found'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/core-app-api/src/routing/FlatRoutes.stable.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.stable.test.tsx new file mode 100644 index 0000000000..93297edc4c --- /dev/null +++ b/packages/core-app-api/src/routing/FlatRoutes.stable.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { render, RenderResult } from '@testing-library/react'; +import React, { ReactNode } from 'react'; +import { MemoryRouter, Route, useOutlet } from 'react-router-dom'; +import { LocalStorageFeatureFlags } from '../apis'; +import { featureFlagsApiRef } from '@backstage/core-plugin-api'; +import { AppContext } from '../app'; +import { AppContextProvider } from '../app/AppContext'; +import { FlatRoutes } from './FlatRoutes'; +import { TestApiProvider } from '@backstage/test-utils'; + +jest.mock('react-router', () => jest.requireActual('react-router-stable')); +jest.mock('react-router-dom', () => + jest.requireActual('react-router-dom-stable'), +); + +const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); +const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + +); + +function makeRouteRenderer(node: ReactNode) { + let rendered: RenderResult | undefined = undefined; + return (path: string) => { + const content = ( + + ({ + NotFoundErrorPage: () => <>Not Found, + }), + } as unknown as AppContext + } + > + + + + ); + if (rendered) { + rendered.unmount(); + rendered.rerender(content); + } else { + rendered = render(content); + } + return rendered; + }; +} + +describe('FlatRoutes', () => { + it('renders some routes', () => { + const renderRoute = makeRouteRenderer( + + a} /> + b} /> + , + ); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('b')).toBeInTheDocument(); + expect(renderRoute('/c').getByText('Not Found')).toBeInTheDocument(); + expect(renderRoute('/b').queryByText('Not Found')).not.toBeInTheDocument(); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + }); + + it('is not sensitive to ordering and overlapping routes', () => { + // The '/*' suffixes here are intentional and will be ignored by FlatRoutes + const routes = ( + <> + a-1} /> + a} /> + a-2} /> + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('a')).toBeInTheDocument(); + expect(renderRoute('/a-1').getByText('a-1')).toBeInTheDocument(); + expect(renderRoute('/a-2').getByText('a-2')).toBeInTheDocument(); + }); + + it('renders children straight as outlets', () => { + const MyPage = () => { + return <>Outlet: {useOutlet()}; + }; + + const routes = ( + <> + }> + a + + }> + a-b + + }> + b + + }>c + + ); + const renderRoute = makeRouteRenderer({routes}); + expect(renderRoute('/a').getByText('Outlet: a')).toBeInTheDocument(); + expect(renderRoute('/a/b').getByText('Outlet: a-b')).toBeInTheDocument(); + expect(renderRoute('/b').getByText('Outlet: b')).toBeInTheDocument(); + expect(renderRoute('/').getByText('Outlet: c')).toBeInTheDocument(); + expect( + renderRoute('/not-found').queryByText('Outlet: c'), + ).not.toBeInTheDocument(); + expect( + renderRoute('/not-found').getByText('Not Found'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx index 5688b2093e..e2db83442e 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.tsx @@ -106,7 +106,7 @@ export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { const withNotFound = [ ...routes, { - path: '/*', + path: '*', element: , }, ];