+ | undefined;
+ return (
+
+ Path at {props.name}: {routeFunc?.(props.params) ?? ''}
+
+ );
+ } catch (ex) {
+ return (
+
+ Error at {props.name}, {String(ex)}
+
+ );
+ }
+};
+
+const ExtensionPage1 = plugin.provide(
+ createRoutableExtension({
+ name: 'ExtensionPage1',
+ component: () => Promise.resolve(MockComponent),
+ mountPoint: refPage1,
+ }),
+);
+const ExtensionPage2 = plugin.provide(
+ createRoutableExtension({
+ name: 'ExtensionPage2',
+ component: () => Promise.resolve(MockComponent),
+ mountPoint: refPage2,
+ }),
+);
+const ExtensionPage3 = plugin.provide(
+ createRoutableExtension({
+ name: 'ExtensionPage3',
+ component: () => Promise.resolve(MockComponent),
+ mountPoint: refPage3,
+ }),
+);
+const ExtensionSource1 = plugin.provide(
+ createRoutableExtension({
+ name: 'ExtensionSource1',
+ component: () => Promise.resolve(MockRouteSource),
+ mountPoint: refSource1,
+ }),
+);
+const ExtensionSource2 = plugin.provide(
+ createRoutableExtension({
+ name: 'ExtensionSource2',
+ component: () => Promise.resolve(MockRouteSource),
+ mountPoint: refSource2,
+ }),
+);
+
+const mockContext = {
+ getComponents: () => ({ Progress: () => null } as any),
+ getSystemIcon: jest.fn(),
+ getSystemIcons: jest.fn(),
+ getPlugins: jest.fn(),
+};
+
+function withRoutingProvider(
+ root: ReactElement,
+ routeBindings: [ExternalRouteRef, RouteRef][] = [],
+) {
+ const { routing } = traverseElementTree({
+ root,
+ discoverers: [childDiscoverer, routeElementDiscoverer],
+ collectors: {
+ routing: routingV2Collector,
+ },
+ });
+
+ return (
+
+ {root}
+
+ );
+}
+
+describe('discovery', () => {
+ it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => {
+ const root = (
+
+
+
+
+
+
+ >
+ }
+ >
+
+ }
+ />
+
+ } />
+
+
+
+
+
+
+
+
+
+ );
+
+ const rendered = render(
+ withRoutingProvider(root, [
+ [eRefA, refPage2],
+ [eRefB, refPage1],
+ [eRefC, refSource1],
+ [eRefD, refPage1],
+ ]),
+ );
+
+ await expect(
+ rendered.findByText('Path at inside: /foo/bar'),
+ ).resolves.toBeInTheDocument();
+ expect(
+ rendered.getByText('Path at insideExternal: /baz'),
+ ).toBeInTheDocument();
+ expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument();
+ expect(
+ rendered.getByText('Path at outsideExternal1: /foo'),
+ ).toBeInTheDocument();
+ expect(
+ rendered.getByText('Path at outsideExternal2: /foo/bar'),
+ ).toBeInTheDocument();
+ expect(
+ rendered.getByText('Path at outsideExternal3: /foo'),
+ ).toBeInTheDocument();
+ expect(
+ rendered.getByText('Path at outsideExternal4: '),
+ ).toBeInTheDocument();
+ });
+
+ it('should handle routeRefs with parameters', async () => {
+ const root = (
+
+
+
+ }>
+
+ }
+ />
+
+
+
+
+
+ );
+
+ const rendered = render(withRoutingProvider(root));
+
+ await expect(
+ rendered.findByText('Path at inside: /foo/bar/bleb'),
+ ).resolves.toBeInTheDocument();
+ expect(
+ rendered.getByText('Path at outside: /foo/bar/blob'),
+ ).toBeInTheDocument();
+ });
+
+ it('should handle relative routing within parameterized routePaths', async () => {
+ const root = (
+
+
+
+
+ }>
+
+ }
+ />
+ } />
+
+
+
+
+
+
+
+ );
+
+ const rendered = render(withRoutingProvider(root));
+
+ await expect(
+ rendered.findByText('Path at inside: /foo/blob/baz'),
+ ).resolves.toBeInTheDocument();
+ rendered.debug();
+ });
+
+ it('should throw errors for routing to other routeRefs with unsupported parameters', () => {
+ const root = (
+
+
+ }>
+ }
+ />
+ } />
+
+
+
+
+
+ );
+
+ const rendered = render(withRoutingProvider(root));
+
+ expect(
+ rendered.getByText(
+ `Error at outsideWithParams, Error: Cannot route to ${refPage2} with parent ${refPage3} as it has parameters`,
+ ),
+ ).toBeInTheDocument();
+ expect(
+ rendered.getByText(
+ `Error at outsideNoParams, Error: Cannot route to ${refPage2} with parent ${refPage3} as it has parameters`,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('should handle relative routing of parameterized routePaths with duplicate param names', () => {
+ const root = (
+
+
+ }>
+ }
+ />
+
+
+
+ );
+
+ const { routing } = traverseElementTree({
+ root,
+ discoverers: [childDiscoverer, routeElementDiscoverer],
+ collectors: {
+ routing: routingV2Collector,
+ },
+ });
+
+ expect(() =>
+ validateRouteParameters(routing.paths, routing.parents),
+ ).toThrow('Parameter :id is duplicated in path foo/:id/bar/:id');
+ });
+});
+
+describe('v1 consumer', () => {
+ function useMockRouteRefV1(
+ routeRef: AnyRouteRef,
+ location: string,
+ ): RouteFunc | undefined {
+ const resolver = useVersionedContext<{
+ 1: RouteResolver;
+ }>('routing-context')?.atVersion(1);
+ if (!resolver) {
+ throw new Error('no impl');
+ }
+ return resolver.resolve(routeRef, location);
+ }
+
+ it('should resolve routes', () => {
+ const routeRef1 = createRouteRef({ id: 'refPage1' });
+ const routeRef2 = createRouteRef({ id: 'refSource1' });
+ const routeRef3 = createRouteRef({ id: 'refPage2', params: ['x'] });
+
+ const renderedHook = renderHook(
+ ({ routeRef }) => useMockRouteRefV1(routeRef, '/'),
+ {
+ initialProps: {
+ routeRef: routeRef1 as AnyRouteRef,
+ },
+ wrapper: ({ children }) => (
+ , string>([
+ [routeRef2, '/foo'],
+ [routeRef3, '/bar/:x'],
+ ])
+ }
+ routeParents={new Map()}
+ routeObjects={[]}
+ routeBindings={new Map()}
+ basePath="/base"
+ children={children}
+ />
+ ),
+ },
+ );
+
+ expect(renderedHook.result.current).toBe(undefined);
+ renderedHook.rerender({ routeRef: routeRef2 });
+ expect(renderedHook.result.current?.()).toBe('/base/foo');
+ renderedHook.rerender({ routeRef: routeRef3 });
+ expect(renderedHook.result.current?.({ x: 'my-x' })).toBe('/base/bar/my-x');
+ });
+});
diff --git a/packages/core-app-api/src/routing/helpers.ts b/packages/core-app-api/src/routing/helpers.ts
new file mode 100644
index 0000000000..b25f602d52
--- /dev/null
+++ b/packages/core-app-api/src/routing/helpers.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2022 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.
+ */
+
+// Joins a list of paths together, avoiding trailing and duplicate slashes
+export function joinPaths(...paths: string[]): string {
+ const normalized = paths.join('/').replace(/\/\/+/g, '/');
+ if (normalized !== '/' && normalized.endsWith('/')) {
+ return normalized.slice(0, -1);
+ }
+ return normalized;
+}
diff --git a/packages/core-app-api/src/routing/validation.ts b/packages/core-app-api/src/routing/validation.ts
index c5d3e4aca9..d8be7ee9bc 100644
--- a/packages/core-app-api/src/routing/validation.ts
+++ b/packages/core-app-api/src/routing/validation.ts
@@ -20,6 +20,7 @@ import {
RouteRef,
SubRouteRef,
} from '@backstage/core-plugin-api';
+import { joinPaths } from './helpers';
import { AnyRouteRef } from './types';
// Validates that there is no duplication of route parameter names
@@ -43,7 +44,7 @@ export function validateRouteParameters(
if (!path) {
throw new Error(`No path for ${currentRouteRef}`);
}
- fullPath = `${path}${fullPath}`;
+ fullPath = joinPaths(path, fullPath);
currentRouteRef = routeParents.get(currentRouteRef);
}