diff --git a/packages/core-api/src/extensions/componentData.test.tsx b/packages/core-api/src/extensions/componentData.test.tsx new file mode 100644 index 0000000000..6c4abda40f --- /dev/null +++ b/packages/core-api/src/extensions/componentData.test.tsx @@ -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 = ; + 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 = ; + 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 = ; + const element2 = ; + 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"', + ); + }); +}); diff --git a/packages/core-api/src/extensions/componentData.tsx b/packages/core-api/src/extensions/componentData.tsx new file mode 100644 index 0000000000..1f88356a48 --- /dev/null +++ b/packages/core-api/src/extensions/componentData.tsx @@ -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; +}; + +type ComponentWithData

= ComponentType

& { + [DATA_KEY]?: DataContainer; +}; + +type ReactNodeWithData = ReactNode & { + type?: { [DATA_KEY]?: DataContainer }; +}; + +export function attachComponentData

( + component: ComponentType

, + type: string, + data: unknown, +) { + const dataComponent = component as ComponentWithData

; + + 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( + 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; +} diff --git a/packages/core-api/src/extensions/extensions.test.tsx b/packages/core-api/src/extensions/extensions.test.tsx new file mode 100644 index 0000000000..92fcecebc4 --- /dev/null +++ b/packages/core-api/src/extensions/extensions.test.tsx @@ -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 = ; + + 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 = ; + const element2 = ; + + expect(getComponentData(element1, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef); + }); +}); diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx new file mode 100644 index 0000000000..4087d5f963 --- /dev/null +++ b/packages/core-api/src/extensions/extensions.tsx @@ -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(options: { + component: ComponentType; + 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> +> { + const { component, mountPoint } = options; + return createReactExtension({ + component, + data: { + 'core.mountPoint': mountPoint, + }, + }); +} + +export function createComponentExtension(options: { + component: ComponentType; +}): Extension> { + const { component } = options; + return createReactExtension({ component }); +} + +export function createReactExtension(options: { + component: ComponentType; + data?: Record; +}): Extension> { + const { component: Component, data = {} } = options; + return { + expose(plugin: BackstagePlugin): NamedExoticComponent { + const Result = (props: 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; + }, + }; +} diff --git a/packages/core-api/src/extensions/index.ts b/packages/core-api/src/extensions/index.ts new file mode 100644 index 0000000000..26a0c597b1 --- /dev/null +++ b/packages/core-api/src/extensions/index.ts @@ -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'; 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/extensions/traversal.ts b/packages/core-api/src/extensions/traversal.ts new file mode 100644 index 0000000000..a1fbdab25a --- /dev/null +++ b/packages/core-api/src/extensions/traversal.ts @@ -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 = () => { + 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(options: { + root: ReactElement; + discoverers: Discoverer[]; + collectors: { [name in keyof Results]: Collector }; +}): Results { + const visited = new Set(); + const collectors: { + [name in string]: ReturnType>; + } = {}; + + // 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( + initialResult: Result, + visit: ReturnType>['visit'], +): Collector { + 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; +} diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx index d69bb7e721..f477674f4d 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -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(extension: Extension): T { + return extension.expose(this); + } + toString() { return `plugin{${this.config.id}}`; } diff --git a/packages/core-api/src/plugin/collectors.test.tsx b/packages/core-api/src/plugin/collectors.test.tsx new file mode 100644 index 0000000000..f040cfd433 --- /dev/null +++ b/packages/core-api/src/plugin/collectors.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, { 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 = ( + + + +
+ +
+
+ {[]} + Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + 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/collectors.ts b/packages/core-api/src/plugin/collectors.ts new file mode 100644 index 0000000000..a222746f9e --- /dev/null +++ b/packages/core-api/src/plugin/collectors.ts @@ -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(), + (acc, node) => { + const plugin = getComponentData(node, 'core.plugin'); + if (plugin) { + acc.add(plugin); + } + }, +); diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index 855264c5f4..dd19948a0a 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -66,10 +66,15 @@ export type PluginOutput = | RedirectRouteOutput | FeatureFlagOutput; +export type Extension = { + expose(plugin: BackstagePlugin): T; +}; + export type BackstagePlugin = { getId(): string; output(): PluginOutput[]; getApis(): Iterable; + provide(extension: Extension): T; }; export type PluginConfig = { diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx new file mode 100644 index 0000000000..c2eefa7e82 --- /dev/null +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -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 = [ +
, +
, +
+ +
, + ]; + + const root = ( + + + +
+ +
+
+ Some text here shouldn't be a problem +
+ {null} +
+ +
+ + {false} + {list} + {true} + {0} +
+ +
+ } /> +
+ + + ); + + 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 = ( + + + + + + + + } + /> + }> + } /> + + + + + ); + + 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 = ; + + expect(() => + traverseElementTree({ + root: ( + + {element} + {element} + + ), + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routeCollector, + routeParents: routeParentCollector, + }, + }), + ).toThrow(`Visited element Extension(MockComponent) twice`); + }); +}); 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; + }, +);