From 632e1986166916e09d4bd8f2d88f2d7b6b14ac06 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Nov 2020 15:36:22 +0100 Subject: [PATCH] core-api: add mount point collection + extension tweaks Co-authored-by: blam --- packages/core-api/src/lib/extensions.tsx | 25 ++- .../core-api/src/routing/discovery.test.tsx | 145 ++++++++++++++++++ packages/core-api/src/routing/discovery.tsx | 75 +++++++++ 3 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 packages/core-api/src/routing/discovery.test.tsx create mode 100644 packages/core-api/src/routing/discovery.tsx diff --git a/packages/core-api/src/lib/extensions.tsx b/packages/core-api/src/lib/extensions.tsx index c0a71a2a90..4087d5f963 100644 --- a/packages/core-api/src/lib/extensions.tsx +++ b/packages/core-api/src/lib/extensions.tsx @@ -14,7 +14,11 @@ * limitations under the License. */ -import React, { ExoticComponent, ComponentType } from 'react'; +import React, { + NamedExoticComponent, + ComponentType, + PropsWithChildren, +} from 'react'; import { RouteRef } from '../routing'; import { attachComponentData } from './componentData'; import { Extension, BackstagePlugin } from '../plugin/types'; @@ -22,7 +26,12 @@ import { Extension, BackstagePlugin } from '../plugin/types'; export function createRoutableExtension(options: { component: ComponentType; mountPoint: RouteRef; -}): Extension> { + // TODO(Rugvip): We want to carry forward the exact props type from the inner component, with + // or without children. ComponentType stops us from doing that though, as it always + // adds children to the props internally. We may want to work around this with custom types. +}): Extension< + NamedExoticComponent> +> { const { component, mountPoint } = options; return createReactExtension({ component, @@ -34,7 +43,7 @@ export function createRoutableExtension(options: { export function createComponentExtension(options: { component: ComponentType; -}): Extension> { +}): Extension> { const { component } = options; return createReactExtension({ component }); } @@ -42,10 +51,10 @@ export function createComponentExtension(options: { export function createReactExtension(options: { component: ComponentType; data?: Record; -}): Extension> { +}): Extension> { const { component: Component, data = {} } = options; return { - expose(plugin: BackstagePlugin): ExoticComponent { + expose(plugin: BackstagePlugin): NamedExoticComponent { const Result = (props: Props) => ; attachComponentData(Result, 'core.plugin', plugin); @@ -53,7 +62,11 @@ export function createReactExtension(options: { attachComponentData(Result, key, value); } - return Result as ExoticComponent; + const name = Component.displayName || Component.name || 'Component'; + if (name) { + Result.displayName = `Extension(${name})`; + } + return Result as NamedExoticComponent; }, }; } diff --git a/packages/core-api/src/routing/discovery.test.tsx b/packages/core-api/src/routing/discovery.test.tsx new file mode 100644 index 0000000000..c894c8ac6d --- /dev/null +++ b/packages/core-api/src/routing/discovery.test.tsx @@ -0,0 +1,145 @@ +/* + * 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 { collectRoutes } from './discovery'; +import { createRouteRef } from './RouteRef'; +import { createPlugin } from '../plugin'; +import { createRoutableExtension } from '../lib/extensions'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const mockConfig = () => ({ path: '/foo', title: 'Foo' }); +const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{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 Extension1 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref1 }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref2 }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref3 }), +); +const Extension4 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref4 }), +); +const Extension5 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref5 }), +); + +describe('discovery', () => { + it('should collect routes', () => { + const list = [ +
, +
, +
+ +
, + ]; + + const routes = collectRoutes( + + + +
+ +
+
+ Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {list} + {true} + {0} +
+ +
+ } /> +
+ + , + ); + + expect(routes).toEqual( + new Map([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]), + ); + }); + + it('should handle all react router Route patterns', () => { + const routes = collectRoutes( + + + + + + + + } + /> + }> + } /> + + + + , + ); + + expect(routes).toEqual( + new Map([ + [ref1, '/foo'], + [ref2, '/bar/:id'], + [ref3, '/baz'], + [ref4, '/divsoup'], + [ref5, '/blop'], + ]), + ); + }); + + it('should not visit the same element twice', () => { + const element = ; + + expect(() => + collectRoutes( + + {element} + {element} + , + ), + ).toThrow(`Visited element Extension(MockComponent) twice`); + }); +}); diff --git a/packages/core-api/src/routing/discovery.tsx b/packages/core-api/src/routing/discovery.tsx new file mode 100644 index 0000000000..61e5df9136 --- /dev/null +++ b/packages/core-api/src/routing/discovery.tsx @@ -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, { isValidElement, ReactNode } from 'react'; +import { RouteRef } from './types'; +import { getComponentData } from '../lib/componentData'; + +export const collectRoutes = (tree: ReactNode) => { + const treeMap = new Map(); + + const visited = new Set(); + const nodes = [tree]; + + while (nodes.length !== 0) { + const node = nodes.shift(); + if (!isIterableElement(node)) { + continue; + } + if (visited.has(node)) { + const anyType = node?.type as + | { displayName?: string; name?: string } + | undefined; + const name = anyType?.displayName || anyType?.name || String(anyType); + throw new Error(`Visited element ${name} twice`); + } + visited.add(node); + + React.Children.forEach(node, child => { + if (!isIterableElement(child)) { + return; + } + + const { path, element, children } = child.props as { + path?: string; + element?: ReactNode; + children?: ReactNode; + }; + if (path) { + const routeRef = getComponentData(child, 'core.mountPoint'); + if (routeRef) { + treeMap.set(routeRef, path); + } else if (isIterableElement(element)) { + const elementRouteRef = getComponentData( + element, + 'core.mountPoint', + ); + if (elementRouteRef) { + treeMap.set(elementRouteRef, path); + } + nodes.push(element.props?.children); + } + } + nodes.push(children); + }); + } + + return treeMap; +}; + +function isIterableElement(node: ReactNode): node is JSX.Element { + return isValidElement(node) || Array.isArray(node); +}