core-api: refactor all element data collection to use the same traversal facilities

Co-authored-by: blam <ben@blam.sh>
This commit is contained in:
Patrik Oldsberg
2020-11-27 11:41:59 +01:00
parent 706868141e
commit 26ed08c167
6 changed files with 217 additions and 116 deletions
@@ -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 = (
<main>
<div>
<h1>Title</h1>
<p>Text</p>
</div>
<hr />
<div>
<h2>Title</h2>
<span>Text</span>
</div>
</main>
);
const { names } = traverseElementTree({
root,
discoverers: [childDiscoverer],
collectors: {
names: createCollector(Array<string>(), (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 = (
<main>
<div>
<h1>Title</h1>
<p>Text</p>
</div>
<hr />
<div>
<h2>Title</h2>
<span>Text</span>
</div>
</main>
);
const { names } = traverseElementTree({
root,
discoverers: [
el =>
Children.toArray(el.props.children).flatMap(child =>
isValidElement(child) ? child?.props?.children : [],
),
],
collectors: {
names: createCollector(Array<string>(), (acc, el) => {
if (typeof el.type === 'string') {
acc.push(el.type);
}
}),
},
});
expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']);
});
});
@@ -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<Result, Context> = () => {
export type Collector<Result, Context> = () => {
accumulator: Result;
visit(
accumulator: Result,
@@ -121,6 +119,13 @@ export function traverseElementTree<Results>(options: {
) as Results;
}
export function createCollector<Result, Context>(
initialResult: Result,
visit: ReturnType<Collector<Result, Context>>['visit'],
): Collector<Result, Context> {
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<Result, Context>(
initialResult: Result,
visit: ReturnType<Collector<Result, Context>>['visit'],
): Collector<Result, Context> {
return () => ({ accumulator: initialResult, visit });
}
export const routeCollector = createCollector(
new Map<RouteRef, string>(),
(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<RouteRef>(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<RouteRef>(
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<RouteRef, RouteRef | undefined>(),
(acc, node, parent, parentRouteRef?: RouteRef) => {
if (parent.props.element === node) {
return parentRouteRef;
}
const element: ReactNode = node.props?.element;
let nextParent = parentRouteRef;
const routeRef = getComponentData<RouteRef>(node, 'core.mountPoint');
if (routeRef) {
acc.set(routeRef, parentRouteRef);
nextParent = routeRef;
} else if (isValidElement(element)) {
const elementRouteRef = getComponentData<RouteRef>(
element,
'core.mountPoint',
);
if (elementRouteRef) {
acc.set(elementRouteRef, parentRouteRef);
nextParent = elementRouteRef;
}
}
return nextParent;
},
);
@@ -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', () => {
</MemoryRouter>
);
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]));
});
});
@@ -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<BackstagePlugin>();
const nodes = [tree];
while (nodes.length !== 0) {
const node = nodes.shift();
if (!isIterableElement(node)) {
continue;
export const pluginCollector = createCollector(
new Set<BackstagePlugin>(),
(acc, node) => {
const plugin = getComponentData<BackstagePlugin>(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<BackstagePlugin>(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);
}
},
);
@@ -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';
@@ -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<RouteRef, string>(),
(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<RouteRef>(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<RouteRef>(
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<RouteRef, RouteRef | undefined>(),
(acc, node, parent, parentRouteRef?: RouteRef) => {
if (parent.props.element === node) {
return parentRouteRef;
}
const element: ReactNode = node.props?.element;
let nextParent = parentRouteRef;
const routeRef = getComponentData<RouteRef>(node, 'core.mountPoint');
if (routeRef) {
acc.set(routeRef, parentRouteRef);
nextParent = routeRef;
} else if (isValidElement(element)) {
const elementRouteRef = getComponentData<RouteRef>(
element,
'core.mountPoint',
);
if (elementRouteRef) {
acc.set(elementRouteRef, parentRouteRef);
nextParent = elementRouteRef;
}
}
return nextParent;
},
);