Merge pull request #3471 from backstage/mob/ind

core-api: add initial set of composability plumbing
This commit is contained in:
Patrik Oldsberg
2020-12-02 11:55:13 +01:00
committed by GitHub
13 changed files with 966 additions and 2 deletions
@@ -0,0 +1,62 @@
/*
* 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 from 'react';
import { attachComponentData, getComponentData } from './componentData';
describe('elementData', () => {
it('should attach a single piece of data', () => {
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
it('should attach several distinct pieces of data', () => {
const data1 = { foo: 'bar' };
const data2 = { test: 'value' };
const Component = () => null;
attachComponentData(Component, 'my-data', data1);
attachComponentData(Component, 'second', data2);
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data1);
expect(getComponentData(element, 'second')).toBe(data2);
});
it('returns undefined for missing data', () => {
const data = { foo: 'bar' };
const Component1 = () => null;
const Component2 = () => null;
attachComponentData(Component2, 'my-data', data);
const element1 = <Component1 />;
const element2 = <Component2 />;
expect(getComponentData(element1, 'missing')).toBeUndefined();
expect(getComponentData(element2, 'missing')).toBeUndefined();
});
it('should throw when attempting to overwrite data', () => {
const data = { foo: 'bar' };
const MyComponent = () => null;
attachComponentData(MyComponent, 'my-data', data);
expect(() => attachComponentData(MyComponent, 'my-data', data)).toThrow(
'Attempted to attach duplicate data "my-data" to component "MyComponent"',
);
});
});
@@ -0,0 +1,69 @@
/*
* 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 { ComponentType, ReactNode } from 'react';
const DATA_KEY = Symbol('backstage-component-data');
type DataContainer = {
map: Map<string, unknown>;
};
type ComponentWithData<P> = ComponentType<P> & {
[DATA_KEY]?: DataContainer;
};
type ReactNodeWithData = ReactNode & {
type?: { [DATA_KEY]?: DataContainer };
};
export function attachComponentData<P>(
component: ComponentType<P>,
type: string,
data: unknown,
) {
const dataComponent = component as ComponentWithData<P>;
let container = dataComponent[DATA_KEY];
if (!container) {
container = dataComponent[DATA_KEY] = { map: new Map() };
}
if (container.map.has(type)) {
const name = component.displayName || component.name;
throw new Error(
`Attempted to attach duplicate data "${type}" to component "${name}"`,
);
}
container.map.set(type, data);
}
export function getComponentData<T>(
node: ReactNode,
type: string,
): T | undefined {
if (!node) {
return undefined;
}
const container = (node as ReactNodeWithData).type?.[DATA_KEY];
if (!container) {
return undefined;
}
return container.map.get(type) as T | undefined;
}
@@ -0,0 +1,72 @@
/*
* 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 from 'react';
import { createPlugin } from '../plugin';
import { createRouteRef } from '../routing';
import { getComponentData } from './componentData';
import {
createComponentExtension,
createReactExtension,
createRoutableExtension,
} from './extensions';
const plugin = createPlugin({
id: 'my-plugin',
});
describe('extensions', () => {
it('should create a react extension with component data', () => {
const Component = () => null;
const extension = createReactExtension({
component: Component,
data: {
myData: { foo: 'bar' },
},
});
const ExtensionComponent = plugin.provide(extension);
const element = <ExtensionComponent />;
expect(getComponentData(element, 'core.plugin')).toBe(plugin);
expect(getComponentData(element, 'myData')).toEqual({ foo: 'bar' });
});
it('should create react extensions of different types', () => {
const Component = () => null;
const routeRef = createRouteRef({ path: '/foo', title: 'Foo' });
const extension1 = createComponentExtension({
component: Component,
});
const extension2 = createRoutableExtension({
component: Component,
mountPoint: routeRef,
});
const ExtensionComponent1 = plugin.provide(extension1);
const ExtensionComponent2 = plugin.provide(extension2);
const element1 = <ExtensionComponent1 />;
const element2 = <ExtensionComponent2 />;
expect(getComponentData(element1, 'core.plugin')).toBe(plugin);
expect(getComponentData(element2, 'core.plugin')).toBe(plugin);
expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef);
});
});
@@ -0,0 +1,72 @@
/*
* 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, {
NamedExoticComponent,
ComponentType,
PropsWithChildren,
} from 'react';
import { RouteRef } from '../routing';
import { attachComponentData } from './componentData';
import { Extension, BackstagePlugin } from '../plugin/types';
export function createRoutableExtension<Props extends {}>(options: {
component: ComponentType<Props>;
mountPoint: RouteRef;
// 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<PropsWithChildren<Props & { path?: string }>>
> {
const { component, mountPoint } = options;
return createReactExtension({
component,
data: {
'core.mountPoint': mountPoint,
},
});
}
export function createComponentExtension<Props extends {}>(options: {
component: ComponentType<Props>;
}): Extension<NamedExoticComponent<Props>> {
const { component } = options;
return createReactExtension({ component });
}
export function createReactExtension<Props extends {}>(options: {
component: ComponentType<Props>;
data?: Record<string, unknown>;
}): Extension<NamedExoticComponent<Props>> {
const { component: Component, data = {} } = options;
return {
expose(plugin: BackstagePlugin): NamedExoticComponent<Props> {
const Result = (props: Props) => <Component {...props} />;
attachComponentData(Result, 'core.plugin', plugin);
for (const [key, value] of Object.entries(data)) {
attachComponentData(Result, key, value);
}
const name = Component.displayName || Component.name || 'Component';
if (name) {
Result.displayName = `Extension(${name})`;
}
return Result as NamedExoticComponent<Props>;
},
};
}
+22
View File
@@ -0,0 +1,22 @@
/*
* 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.
*/
export { attachComponentData, getComponentData } from './componentData';
export {
createReactExtension,
createRoutableExtension,
createComponentExtension,
} from './extensions';
@@ -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']);
});
});
@@ -0,0 +1,138 @@
/*
* 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, ReactElement, Children } from 'react';
export type Discoverer = (element: ReactElement) => ReactNode;
export type Collector<Result, Context> = () => {
accumulator: Result;
visit(
accumulator: Result,
element: ReactElement,
parent: ReactElement,
context: Context,
): Context;
};
/**
* A function that allows you to traverse a tree of React elements using
* varying methods to discover child nodes and collect data along the way.
*/
export function traverseElementTree<Results>(options: {
root: ReactElement;
discoverers: Discoverer[];
collectors: { [name in keyof Results]: Collector<Results[name], any> };
}): Results {
const visited = new Set();
const collectors: {
[name in string]: ReturnType<Collector<any, any>>;
} = {};
// Bootstrap all collectors, initializing the accumulators and providing the visitor function
for (const name in options.collectors) {
if (options.collectors.hasOwnProperty(name)) {
collectors[name] = options.collectors[name]();
}
}
// Internal representation of an element in the tree that we're iterating over
type QueueItem = {
node: ReactNode;
parent: ReactElement;
contexts: { [name in string]: unknown };
};
const queue = [
{
node: Children.toArray(options.root),
parent: options.root,
contexts: {},
} as QueueItem,
];
while (queue.length !== 0) {
const { node, parent, contexts } = queue.shift()!;
// While the parent and the element we pass on to collectors and discoverers
// have been validated and are known to be React elements, the child nodes
// emitted by the discoverers are not.
Children.forEach(node, element => {
if (!isValidElement(element)) {
return;
}
if (visited.has(element)) {
const anyType = element?.type as
| { displayName?: string; name?: string }
| undefined;
const name = anyType?.displayName || anyType?.name || String(anyType);
throw new Error(`Visited element ${name} twice`);
}
visited.add(element);
const nextContexts: QueueItem['contexts'] = {};
// Collectors populate their result data using the current node, and compute
// context for the next iteration
for (const name in collectors) {
if (collectors.hasOwnProperty(name)) {
const collector = collectors[name];
nextContexts[name] = collector.visit(
collector.accumulator,
element,
parent,
contexts[name],
);
}
}
// Discoverers provide ways to continue the traversal from the current element
for (const discoverer of options.discoverers) {
const children = discoverer(element);
if (children) {
queue.push({
node: children,
parent: element,
contexts: nextContexts,
});
}
}
});
}
return Object.fromEntries(
Object.entries(collectors).map(([name, c]) => [name, c.accumulator]),
) 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;
}
export function routeElementDiscoverer(element: ReactElement): ReactNode {
if (element.props?.path && element.props?.element) {
return element.props?.element;
}
return undefined;
}
+11 -2
View File
@@ -14,10 +14,15 @@
* limitations under the License.
*/
import { PluginConfig, PluginOutput, BackstagePlugin } from './types';
import {
PluginConfig,
PluginOutput,
BackstagePlugin,
Extension,
} from './types';
import { AnyApiFactory } from '../apis';
export class PluginImpl {
export class PluginImpl implements BackstagePlugin {
private storedOutput?: PluginOutput[];
constructor(private readonly config: PluginConfig) {}
@@ -65,6 +70,10 @@ export class PluginImpl {
return this.storedOutput;
}
provide<T>(extension: Extension<T>): T {
return extension.expose(this);
}
toString() {
return `plugin{${this.config.id}}`;
}
@@ -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, { PropsWithChildren } from 'react';
import { createRouteRef } from '../routing';
import { createPlugin } from './Plugin';
import {
createRoutableExtension,
createComponentExtension,
} from '../extensions';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import {
traverseElementTree,
childDiscoverer,
routeElementDiscoverer,
} from '../extensions/traversal';
import { pluginCollector } from './collectors';
const mockConfig = () => ({ path: '/foo', title: 'Foo' });
const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}</>;
const pluginA = createPlugin({ id: 'my-plugin-a' });
const pluginB = createPlugin({ id: 'my-plugin-b' });
const pluginC = createPlugin({ id: 'my-plugin-c' });
const ref1 = createRouteRef(mockConfig());
const ref2 = createRouteRef(mockConfig());
const Extension1 = pluginA.provide(
createRoutableExtension({ component: MockComponent, mountPoint: ref1 }),
);
const Extension2 = pluginB.provide(
createRoutableExtension({ component: MockComponent, mountPoint: ref2 }),
);
const Extension3 = pluginA.provide(
createComponentExtension({ component: MockComponent }),
);
const Extension4 = pluginB.provide(
createComponentExtension({ component: MockComponent }),
);
const Extension5 = pluginC.provide(
createComponentExtension({ component: MockComponent }),
);
describe('collection', () => {
it('should collect the plugins', () => {
const root = (
<MemoryRouter>
<Routes>
<Extension1 path="/foo">
<div>
<Extension2 path="/bar/:id">
<div>
<div />
{[<Extension4 key={0} />]}
Some text here shouldn't be a problem
<div />
{null}
<div />
<Extension3 />
</div>
</Extension2>
{false}
{true}
{0}
</div>
</Extension1>
<div>
<Route path="/divsoup" element={<Extension5 />} />
</div>
</Routes>
</MemoryRouter>
);
const { plugins } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
plugins: pluginCollector,
},
});
expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC]));
});
});
@@ -0,0 +1,44 @@
/*
* 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.
*/
/*
* 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 { BackstagePlugin } from './types';
import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
export const pluginCollector = createCollector(
new Set<BackstagePlugin>(),
(acc, node) => {
const plugin = getComponentData<BackstagePlugin>(node, 'core.plugin');
if (plugin) {
acc.add(plugin);
}
},
);
+5
View File
@@ -66,10 +66,15 @@ export type PluginOutput =
| RedirectRouteOutput
| FeatureFlagOutput;
export type Extension<T> = {
expose(plugin: BackstagePlugin): T;
};
export type BackstagePlugin = {
getId(): string;
output(): PluginOutput[];
getApis(): Iterable<AnyApiFactory>;
provide<T>(extension: Extension<T>): T;
};
export type PluginConfig = {
@@ -0,0 +1,193 @@
/*
* 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 { routeCollector, routeParentCollector } from './collectors';
import {
traverseElementTree,
childDiscoverer,
routeElementDiscoverer,
} from '../extensions/traversal';
import { createRouteRef } from './RouteRef';
import { createPlugin } from '../plugin';
import { createRoutableExtension } from '../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 = [
<div key={0} />,
<div key={1} />,
<div key={3}>
<Extension5 path="/blop" />
</div>,
];
const root = (
<MemoryRouter>
<Routes>
<Extension1 path="/foo">
<div>
<Extension2 path="/bar/:id">
<div>
<div />
Some text here shouldn't be a problem
<div />
{null}
<div />
<Extension3 path="/baz" />
</div>
</Extension2>
{false}
{list}
{true}
{0}
</div>
</Extension1>
<div>
<Route path="/divsoup" element={<Extension4 />} />
</div>
</Routes>
</MemoryRouter>
);
const { routes, routeParents } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routes: routeCollector,
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],
]),
);
});
it('should handle all react router Route patterns', () => {
const root = (
<MemoryRouter>
<Routes>
<Route
path="/foo"
element={
<Extension1>
<Routes>
<Extension2 path="/bar/:id" />
</Routes>
</Extension1>
}
/>
<Route path="/baz" element={<Extension3 path="/not-used" />}>
<Route path="/divsoup" element={<Extension4 />} />
<Extension5 path="/blop" />
</Route>
</Routes>
</MemoryRouter>
);
const { routes, routeParents } = traverseElementTree({
root,
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routes: routeCollector,
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, undefined],
[ref4, ref3],
[ref5, ref3],
]),
);
});
it('should not visit the same element twice', () => {
const element = <Extension3 path="/baz" />;
expect(() =>
traverseElementTree({
root: (
<MemoryRouter>
<Extension1 path="/foo">{element}</Extension1>
<Extension2 path="/bar">{element}</Extension2>
</MemoryRouter>
),
discoverers: [childDiscoverer, routeElementDiscoverer],
collectors: {
routes: routeCollector,
routeParents: routeParentCollector,
},
}),
).toThrow(`Visited element Extension(MockComponent) twice`);
});
});
@@ -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;
},
);