Merge pull request #3808 from backstage/mob/gmp

core-api: implement mount point gathering for entity page tabs
This commit is contained in:
Patrik Oldsberg
2020-12-22 10:06:39 +01:00
committed by GitHub
2 changed files with 211 additions and 48 deletions
+163 -42
View File
@@ -24,21 +24,22 @@ import {
} from '../extensions/traversal';
import { createRouteRef } from './RouteRef';
import { createPlugin } from '../plugin';
import { createRoutableExtension } from '../extensions';
import { attachComponentData, createRoutableExtension } from '../extensions';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { RouteRef } from './types';
const mockConfig = () => ({ path: '/foo', title: 'Foo' });
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
<>{children}</>
);
const plugin = createPlugin({ id: 'my-plugin' });
const ref1 = createRouteRef(mockConfig());
const ref2 = createRouteRef(mockConfig());
const ref3 = createRouteRef(mockConfig());
const ref4 = createRouteRef(mockConfig());
const ref5 = createRouteRef(mockConfig());
const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' });
const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' });
const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' });
const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' });
const ref5 = createRouteRef({ path: '/foo5', title: 'Foo' });
const refOrder = [ref1, ref2, ref3, ref4, ref5];
const Extension1 = plugin.provide(
createRoutableExtension({
@@ -71,6 +72,20 @@ const Extension5 = plugin.provide(
}),
);
const AggregationComponent = ({
children,
}: PropsWithChildren<{
path: string;
}>) => <>{children}</>;
attachComponentData(AggregationComponent, 'core.gatherMountPoints', true);
function sortedEntries<T>(map: Map<RouteRef, T>): [RouteRef, T][] {
return Array.from(map).sort(
([a], [b]) => refOrder.indexOf(a) - refOrder.indexOf(b),
);
}
describe('discovery', () => {
it('should collect routes', () => {
const list = [
@@ -117,25 +132,20 @@ describe('discovery', () => {
routeParents: routeParentCollector,
},
});
expect(routes).toEqual(
new Map([
[ref1, '/foo'],
[ref2, '/bar/:id'],
[ref3, '/baz'],
[ref4, '/divsoup'],
[ref5, '/blop'],
]),
);
expect(routeParents).toEqual(
new Map([
[ref1, undefined],
[ref2, ref1],
[ref3, ref2],
[ref4, undefined],
[ref5, ref1],
]),
);
expect(sortedEntries(routes)).toEqual([
[ref1, '/foo'],
[ref2, '/bar/:id'],
[ref3, '/baz'],
[ref4, '/divsoup'],
[ref5, '/blop'],
]);
expect(sortedEntries(routeParents)).toEqual([
[ref1, undefined],
[ref2, ref1],
[ref3, ref2],
[ref4, undefined],
[ref5, ref1],
]);
});
it('should handle all react router Route patterns', () => {
@@ -168,24 +178,135 @@ describe('discovery', () => {
routeParents: routeParentCollector,
},
});
expect(routes).toEqual(
new Map([
[ref1, '/foo'],
[ref2, '/bar/:id'],
[ref3, '/baz'],
[ref4, '/divsoup'],
[ref5, '/blop'],
]),
expect(sortedEntries(routes)).toEqual([
[ref1, '/foo'],
[ref2, '/bar/:id'],
[ref3, '/baz'],
[ref4, '/divsoup'],
[ref5, '/blop'],
]);
expect(sortedEntries(routeParents)).toEqual([
[ref1, undefined],
[ref2, ref1],
[ref3, undefined],
[ref4, ref3],
[ref5, ref3],
]);
});
it('should use the route aggregator key to bind child routes to the same path', () => {
const root = (
<MemoryRouter>
<Routes>
<AggregationComponent path="/foo">
<Extension1 />
<div>
<Extension2 />
</div>
HELLO
</AggregationComponent>
<Extension3 path="/bar">
<AggregationComponent path="/baz">
<Extension4>
<Extension5 />
</Extension4>
</AggregationComponent>
</Extension3>
</Routes>
</MemoryRouter>
);
expect(routeParents).toEqual(
new Map([
[ref1, undefined],
[ref2, ref1],
[ref3, undefined],
[ref4, ref3],
[ref5, ref3],
]),
const { routes, routeParents } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routes: routePathCollector,
routeParents: routeParentCollector,
},
});
expect(sortedEntries(routes)).toEqual([
[ref1, '/foo'],
[ref2, '/foo'],
[ref3, '/bar'],
[ref4, '/baz'],
[ref5, '/baz'],
]);
expect(sortedEntries(routeParents)).toEqual([
[ref1, undefined],
[ref2, undefined],
[ref3, undefined],
[ref4, ref3],
[ref5, ref3],
]);
});
it('should use the route aggregator but stop when encountering explicit path', () => {
const root = (
<MemoryRouter>
<Routes>
<Extension1 path="/foo">
<AggregationComponent path="/bar">
<Extension2>
<Extension3 path="/baz">
<Extension4 path="/blop" />
</Extension3>
<Extension5 />
</Extension2>
</AggregationComponent>
</Extension1>
</Routes>
</MemoryRouter>
);
const { routes, routeParents } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routes: routePathCollector,
routeParents: routeParentCollector,
},
});
expect(sortedEntries(routes)).toEqual([
[ref1, '/foo'],
[ref2, '/bar'],
[ref3, '/baz'],
[ref4, '/blop'],
[ref5, '/bar'],
]);
expect(sortedEntries(routeParents)).toEqual([
[ref1, undefined],
[ref2, ref1],
[ref3, ref1],
[ref4, ref3],
[ref5, ref1],
]);
});
it('should stop gathering mount points after encountering explicit path', () => {
const root = (
<MemoryRouter>
<Routes>
<Extension1 path="/foo">
<AggregationComponent path="/bar">
<Extension2 path="/baz">
<Extension3 />
</Extension2>
</AggregationComponent>
</Extension1>
</Routes>
</MemoryRouter>
);
expect(() => {
traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routes: routePathCollector,
routeParents: routeParentCollector,
},
});
}).toThrow('Mounted routable extension must have a path');
});
it('should not visit the same element twice', () => {
+48 -6
View File
@@ -32,25 +32,48 @@ function getMountPoint(node: ReactElement): RouteRef | undefined {
export const routePathCollector = createCollector(
() => new Map<RouteRef, string>(),
(acc, node, parent) => {
(acc, node, parent, ctxPath: string | undefined) => {
// The context path is used during mount point gathering to assign the same path
// to all discovered mount points
let currentCtxPath = ctxPath;
if (parent?.props.element === node) {
return;
return currentCtxPath;
}
// Start gathering mount points when we encounter a mount point gathering flag
if (getComponentData<boolean>(node, 'core.gatherMountPoints')) {
const path: string | undefined = node.props?.path;
if (!path) {
throw new Error('Mount point gatherer must have a path');
}
currentCtxPath = path;
}
const routeRef = getMountPoint(node);
if (routeRef) {
const path: string | undefined = node.props?.path;
let path: string | undefined = node.props?.path;
// If we're gathering mount points we use the context path as out path, unless
// the element has its own path, in which case we use that instead and stop gathering
if (currentCtxPath) {
if (path) {
currentCtxPath = undefined;
} else {
path = currentCtxPath;
}
}
if (!path) {
throw new Error('Mounted routable extension must have a path');
}
acc.set(routeRef, path);
}
return currentCtxPath;
},
);
export const routeParentCollector = createCollector(
() => new Map<RouteRef, RouteRef | undefined>(),
(acc, node, parent, parentRouteRef?: RouteRef) => {
(acc, node, parent, parentRouteRef?: RouteRef | { sticky: RouteRef }) => {
if (parent?.props.element === node) {
return parentRouteRef;
}
@@ -59,8 +82,27 @@ export const routeParentCollector = createCollector(
const routeRef = getMountPoint(node);
if (routeRef) {
acc.set(routeRef, parentRouteRef);
nextParent = routeRef;
// "sticky" route ref is when we've encountered a mount point gatherer, and we want a
// mount points beneath it to have the same parent, regardless of internal structure
if (parentRouteRef && 'sticky' in parentRouteRef) {
acc.set(routeRef, parentRouteRef.sticky);
// When we encounter a mount point with an explicit path, we stop gathering
// mount points withing the children and remove the sticky state
if (node.props?.path) {
nextParent = routeRef;
} else {
nextParent = parentRouteRef;
}
} else {
acc.set(routeRef, parentRouteRef);
nextParent = routeRef;
}
}
// Mount point gatherers are marked as "sticky"
if (getComponentData<boolean>(node, 'core.gatherMountPoints')) {
return { sticky: nextParent };
}
return nextParent;