Merge pull request #3471 from backstage/mob/ind
core-api: add initial set of composability plumbing
This commit is contained in:
@@ -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>;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user