diff --git a/packages/core-api/src/extensions/traversal.test.tsx b/packages/core-api/src/extensions/traversal.test.tsx new file mode 100644 index 0000000000..a69ee9de77 --- /dev/null +++ b/packages/core-api/src/extensions/traversal.test.tsx @@ -0,0 +1,98 @@ +/* + * 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, { Children, isValidElement } from 'react'; +import { + childDiscoverer, + createCollector, + traverseElementTree, +} from './traversal'; + +describe('discovery', () => { + it('should collect element names', () => { + const root = ( +
+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [childDiscoverer], + collectors: { + names: createCollector(Array(), (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }), + }, + }); + + expect(names).toEqual([ + 'main', + 'div', + 'hr', + 'div', + 'h1', + 'p', + 'h2', + 'span', + ]); + }); + + it('should collect element names while skipping one level of children', () => { + const root = ( +
+
+

Title

+

Text

+
+
+
+

Title

+ Text +
+
+ ); + + const { names } = traverseElementTree({ + root, + discoverers: [ + el => + Children.toArray(el.props.children).flatMap(child => + isValidElement(child) ? child?.props?.children : [], + ), + ], + collectors: { + names: createCollector(Array(), (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }), + }, + }); + + expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']); + }); +}); diff --git a/packages/core-api/src/routing/discovery.tsx b/packages/core-api/src/extensions/traversal.ts similarity index 68% rename from packages/core-api/src/routing/discovery.tsx rename to packages/core-api/src/extensions/traversal.ts index 0189ae3a4e..a1fbdab25a 100644 --- a/packages/core-api/src/routing/discovery.tsx +++ b/packages/core-api/src/extensions/traversal.ts @@ -15,12 +15,10 @@ */ import { isValidElement, ReactNode, ReactElement, Children } from 'react'; -import { RouteRef } from './types'; -import { getComponentData } from '../extensions'; -type Discoverer = (element: ReactElement) => ReactNode; +export type Discoverer = (element: ReactElement) => ReactNode; -type Collector = () => { +export type Collector = () => { accumulator: Result; visit( accumulator: Result, @@ -121,6 +119,13 @@ export function traverseElementTree(options: { ) as Results; } +export function createCollector( + initialResult: Result, + visit: ReturnType>['visit'], +): Collector { + return () => ({ accumulator: initialResult, visit }); +} + export function childDiscoverer(element: ReactElement): ReactNode { return element.props?.children; } @@ -131,72 +136,3 @@ export function routeElementDiscoverer(element: ReactElement): ReactNode { } return undefined; } - -function createCollector( - initialResult: Result, - visit: ReturnType>['visit'], -): Collector { - return () => ({ accumulator: initialResult, visit }); -} - -export const routeCollector = createCollector( - new Map(), - (acc, node, parent) => { - if (parent.props.element === node) { - return; - } - - const path: string | undefined = node.props?.path; - const element: ReactNode = node.props?.element; - - const routeRef = getComponentData(node, 'core.mountPoint'); - if (routeRef) { - if (!path) { - throw new Error('Mounted routable extension must have a path'); - } - acc.set(routeRef, path); - } else if (isValidElement(element)) { - const elementRouteRef = getComponentData( - element, - 'core.mountPoint', - ); - if (elementRouteRef) { - if (!path) { - throw new Error('Route element must have a path'); - } - acc.set(elementRouteRef, path); - } - } - }, -); - -export const routeParentCollector = createCollector( - new Map(), - (acc, node, parent, parentRouteRef?: RouteRef) => { - if (parent.props.element === node) { - return parentRouteRef; - } - - const element: ReactNode = node.props?.element; - - let nextParent = parentRouteRef; - - const routeRef = getComponentData(node, 'core.mountPoint'); - if (routeRef) { - acc.set(routeRef, parentRouteRef); - nextParent = routeRef; - } else if (isValidElement(element)) { - const elementRouteRef = getComponentData( - element, - 'core.mountPoint', - ); - - if (elementRouteRef) { - acc.set(elementRouteRef, parentRouteRef); - nextParent = elementRouteRef; - } - } - - return nextParent; - }, -); diff --git a/packages/core-api/src/plugin/collection.test.tsx b/packages/core-api/src/plugin/collectors.test.tsx similarity index 84% rename from packages/core-api/src/plugin/collection.test.tsx rename to packages/core-api/src/plugin/collectors.test.tsx index fd94303ef4..f040cfd433 100644 --- a/packages/core-api/src/plugin/collection.test.tsx +++ b/packages/core-api/src/plugin/collectors.test.tsx @@ -16,13 +16,18 @@ import React, { PropsWithChildren } from 'react'; import { createRouteRef } from '../routing'; -import { createPlugin } from '../plugin'; +import { createPlugin } from './Plugin'; import { createRoutableExtension, createComponentExtension, } from '../extensions'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; -import { collectPlugins } from './collection'; +import { + traverseElementTree, + childDiscoverer, + routeElementDiscoverer, +} from '../extensions/traversal'; +import { pluginCollector } from './collectors'; const mockConfig = () => ({ path: '/foo', title: 'Foo' }); const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}; @@ -80,6 +85,14 @@ describe('collection', () => { ); - expect(collectPlugins(root)).toEqual(new Set([pluginA, pluginB, pluginC])); + const { plugins } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + plugins: pluginCollector, + }, + }); + + expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC])); }); }); diff --git a/packages/core-api/src/plugin/collection.ts b/packages/core-api/src/plugin/collectors.ts similarity index 58% rename from packages/core-api/src/plugin/collection.ts rename to packages/core-api/src/plugin/collectors.ts index 0811f66445..a222746f9e 100644 --- a/packages/core-api/src/plugin/collection.ts +++ b/packages/core-api/src/plugin/collectors.ts @@ -29,44 +29,16 @@ * limitations under the License. */ -import React, { isValidElement, ReactNode } from 'react'; import { BackstagePlugin } from './types'; import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; -export const collectPlugins = (tree: ReactNode) => { - const plugins = new Set(); - - const nodes = [tree]; - - while (nodes.length !== 0) { - const node = nodes.shift(); - if (!isIterableElement(node)) { - continue; +export const pluginCollector = createCollector( + new Set(), + (acc, node) => { + const plugin = getComponentData(node, 'core.plugin'); + if (plugin) { + acc.add(plugin); } - - React.Children.forEach(node, child => { - if (!isIterableElement(child)) { - return; - } - - const { element, children } = child.props as { - element?: ReactNode; - children?: ReactNode; - }; - const plugin = getComponentData(child, 'core.plugin'); - if (plugin) { - plugins.add(plugin); - } - if (isIterableElement(element)) { - nodes.push(element); - } - nodes.push(children); - }); - } - - return plugins; -}; - -function isIterableElement(node: ReactNode): node is JSX.Element { - return isValidElement(node) || Array.isArray(node); -} + }, +); diff --git a/packages/core-api/src/routing/discovery.test.tsx b/packages/core-api/src/routing/collectors.test.tsx similarity index 98% rename from packages/core-api/src/routing/discovery.test.tsx rename to packages/core-api/src/routing/collectors.test.tsx index c702811529..c2eefa7e82 100644 --- a/packages/core-api/src/routing/discovery.test.tsx +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -15,13 +15,13 @@ */ import React, { PropsWithChildren } from 'react'; +import { routeCollector, routeParentCollector } from './collectors'; + import { traverseElementTree, childDiscoverer, routeElementDiscoverer, - routeCollector, - routeParentCollector, -} from './discovery'; +} from '../extensions/traversal'; import { createRouteRef } from './RouteRef'; import { createPlugin } from '../plugin'; import { createRoutableExtension } from '../extensions'; diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx new file mode 100644 index 0000000000..fab0cbe112 --- /dev/null +++ b/packages/core-api/src/routing/collectors.tsx @@ -0,0 +1,82 @@ +/* + * 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 { isValidElement, ReactNode } from 'react'; +import { RouteRef } from '../routing/types'; +import { getComponentData } from '../extensions'; +import { createCollector } from '../extensions/traversal'; + +export const routeCollector = createCollector( + new Map(), + (acc, node, parent) => { + if (parent.props.element === node) { + return; + } + + const path: string | undefined = node.props?.path; + const element: ReactNode = node.props?.element; + + const routeRef = getComponentData(node, 'core.mountPoint'); + if (routeRef) { + if (!path) { + throw new Error('Mounted routable extension must have a path'); + } + acc.set(routeRef, path); + } else if (isValidElement(element)) { + const elementRouteRef = getComponentData( + element, + 'core.mountPoint', + ); + if (elementRouteRef) { + if (!path) { + throw new Error('Route element must have a path'); + } + acc.set(elementRouteRef, path); + } + } + }, +); + +export const routeParentCollector = createCollector( + new Map(), + (acc, node, parent, parentRouteRef?: RouteRef) => { + if (parent.props.element === node) { + return parentRouteRef; + } + + const element: ReactNode = node.props?.element; + + let nextParent = parentRouteRef; + + const routeRef = getComponentData(node, 'core.mountPoint'); + if (routeRef) { + acc.set(routeRef, parentRouteRef); + nextParent = routeRef; + } else if (isValidElement(element)) { + const elementRouteRef = getComponentData( + element, + 'core.mountPoint', + ); + + if (elementRouteRef) { + acc.set(elementRouteRef, parentRouteRef); + nextParent = elementRouteRef; + } + } + + return nextParent; + }, +);