diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx index 498d1dd9fc..4441d4abf3 100644 --- a/packages/core-api/src/routing/collectors.test.tsx +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -38,7 +38,7 @@ const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( const plugin = createPlugin({ id: 'my-plugin' }); -const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); +/const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' }); const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' }); const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' }); diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx index 9e3a0a9956..c185373dc1 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.tsx @@ -14,79 +14,49 @@ * limitations under the License. */ -import React, { ReactNode, Children, isValidElement, Fragment } from 'react'; +import React, { ReactNode } from 'react'; import { useRoutes } from 'react-router-dom'; -import { - useApi, - useApp, - featureFlagsApiRef, - FeatureFlagsApi, -} from '@backstage/core-plugin-api'; -import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged'; +import { useApp, useElementCollection } from '@backstage/core-plugin-api'; type RouteObject = { path: string; - element: JSX.Element; + element: ReactNode; children?: RouteObject[]; }; -// Similar to the same function from react-router, this collects routes from the -// children, but only the first level of routes -function createRoutesFromChildren( - childrenNode: ReactNode, - featureFlagsApi: FeatureFlagsApi, -): RouteObject[] { - return Children.toArray(childrenNode).flatMap(child => { - if (!isValidElement(child)) { - return []; - } - - const { children } = child.props; - - if (child.type === Fragment) { - return createRoutesFromChildren(children, featureFlagsApi); - } - - if (child.type === FeatureFlagged) { - const { flag } = child.props as FeatureFlaggedProps; - if (featureFlagsApi.isActive(flag)) { - return createRoutesFromChildren(children, featureFlagsApi); - } - return []; - } - - let path = child.props.path as string | undefined; - - // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone - if (path === '') { - return []; - } - path = path?.replace(/\/\*$/, '') ?? '/'; - - return [ - { - path, - element: child, - children: children && [ - { - path: '/*', - element: children, - }, - ], - }, - ]; - }); -} - type FlatRoutesProps = { children: ReactNode; }; export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { const app = useApp(); - const featureFlagsApi = useApi(featureFlagsApiRef); const { NotFoundErrorPage } = app.getComponents(); - const routes = createRoutesFromChildren(props.children, featureFlagsApi) + const routes = useElementCollection(props.children) + .listElements<{ path?: string; children: ReactNode }>() + .flatMap(child => { + let path = child.props.path; + + // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone + if (path === '') { + return []; + } + path = path?.replace(/\/\*$/, '') ?? '/'; + + return [ + { + path, + element: child, + children: child.props.children + ? [ + { + path: '/*', + element: child.props.children, + }, + ] + : undefined, + }, + ]; + }) // Routes are sorted to work around a bug where prefixes are unexpectedly matched .sort((a, b) => b.path.localeCompare(a.path)) // We make sure all routes have '/*' appended, except '/' diff --git a/packages/core-app-api/src/routing/index.ts b/packages/core-app-api/src/routing/index.ts index 7e82eeb25d..b37b51e919 100644 --- a/packages/core-app-api/src/routing/index.ts +++ b/packages/core-app-api/src/routing/index.ts @@ -15,4 +15,5 @@ */ export { FlatRoutes } from './FlatRoutes'; -export { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged'; +export { FeatureFlagged } from './FeatureFlagged'; +export type { FeatureFlaggedProps } from './FeatureFlagged'; diff --git a/packages/core-plugin-api/src/extensions/index.ts b/packages/core-plugin-api/src/extensions/index.ts index 26a0c597b1..8d2d6872d9 100644 --- a/packages/core-plugin-api/src/extensions/index.ts +++ b/packages/core-plugin-api/src/extensions/index.ts @@ -20,3 +20,4 @@ export { createRoutableExtension, createComponentExtension, } from './extensions'; +export { useElementCollection } from './pennywise'; diff --git a/packages/core-plugin-api/src/extensions/pennywise.ts b/packages/core-plugin-api/src/extensions/pennywise.ts index ce365a5a0f..d6d92a267e 100644 --- a/packages/core-plugin-api/src/extensions/pennywise.ts +++ b/packages/core-plugin-api/src/extensions/pennywise.ts @@ -13,98 +13,100 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { ReactNode } from 'react'; +import { + Children, + Fragment, + isValidElement, + ReactNode, + ReactElement, +} from 'react'; import { getComponentData } from './componentData'; +import { useApi, FeatureFlagsApi, featureFlagsApiRef } from '../apis'; -/** - * Returns an array of each component data value for a given key of each - * element in the entire react element tree starting at the provided children. - * - * - This was needed to grab the actual component data once we had narrowed down the set of children - */ -export const useCollectComponentData = ( - children: React.ReactNode, - componentDataKey: string, -) => { - const stack = [children]; - const found: T[] = []; +function selectChildren( + rootNode: ReactNode, + featureFlagsApi: FeatureFlagsApi, + selector?: (element: ReactElement) => boolean, + strictError?: string, +): Array> { + return Children.toArray(rootNode).flatMap(node => { + if (!isValidElement(node)) { + return []; + } - while (stack.length) { - const current: React.ReactNode = stack.pop()!; + const { children } = node.props; - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; + if (node.type === Fragment) { + return selectChildren(children, featureFlagsApi, selector, strictError); + } + + if (getComponentData(node, 'core.featureFlagged')) { + const { flag } = node.props as { flag: string }; + if (featureFlagsApi.isActive(flag)) { + return selectChildren( + node.props.children, + featureFlagsApi, + selector, + strictError, + ); } + return []; + } - const data = getComponentData(child, componentDataKey); - if (data) { - found.push(data); - } + if (selector === undefined || selector(node)) { + return [node]; + } - if (child.props.children) { - stack.push(child.props.children); - } - }); - } + if (strictError) { + throw new Error(strictError); + } - return found; -}; - -/** - * Returns an array of all values of the children prop of each element with the entire - * react element tree that has component data for the given key. - * - * - this was needed to collect the children of ScaffolderFieldExtensions elements - */ -export const useCollectChildren = ( - component: React.ReactNode, - componentDataKey: string, -) => { - const stack = [component]; - const found: React.ReactNode[] = []; - - while (stack.length) { - const current: React.ReactNode = stack.pop()!; - - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; - } - - if (child.props.children) { - if (getComponentData(child, componentDataKey)) { - found.push(child.props.children); - } - stack.push(child.props.children); - } - }); - } - - return found; -}; - -/* - * - * - * TODO: - * support: - * - entity layout route traversal - * - scaffolder field extension enumeration - * - FlatRoutes - * - Respecting feature flags - */ + return selectChildren( + node.props.children, + featureFlagsApi, + selector, + strictError, + ); + }); +} class ElementCollection { - constructor(private readonly children: ReactNode) {} + constructor( + private readonly children: ReactNode, + private readonly featureFlagsApi: FeatureFlagsApi, + ) {} + findByComponentData(query: { key: string; withStrictError?: string }) { - const next = applyFilterStuff(this.children); - return new ElementCollection(next); + const selection = selectChildren( + this.children, + this.featureFlagsApi, + node => Boolean(getComponentData(node, query.key)), + query.withStrictError, + ); + return new ElementCollection(selection, this.featureFlagsApi); + } + + listComponentData(query: { key: string }): T[] { + const selection = selectChildren( + this.children, + this.featureFlagsApi, + node => Boolean(getComponentData(node, query.key)), + ); + return selection + .map(node => getComponentData(node, query.key)) + .filter((data: T | undefined): data is T => Boolean(data)); + } + + listElements(): Array< + ReactElement + > { + return selectChildren(this.children, this.featureFlagsApi) as Array< + ReactElement + >; } - listComponentData(query: { key: string }): T[] {} - // listElements } export function useElementCollection(children: ReactNode) { - return new ElementCollection(children); + const featureFlagsApi = useApi(featureFlagsApiRef); + return new ElementCollection(children, featureFlagsApi); } diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 68b254ec59..7cd4ab3dd1 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -28,11 +28,8 @@ import { Page, Progress, RoutedTabs, - FeatureFlagsApi, - getComponentData, - featureFlagsApiRef, - useApi, } from '@backstage/core'; +import { useElementCollection } from '@backstage/core-plugin-api'; import { EntityContext, EntityRefLinks, @@ -41,14 +38,7 @@ import { } from '@backstage/plugin-catalog-react'; import { Box, TabProps } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import { - Children, - default as React, - Fragment, - isValidElement, - useContext, - useState, -} from 'react'; +import React, { useContext, useState } from 'react'; import { useNavigate } from 'react-router'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; @@ -172,9 +162,9 @@ export const EntityLayout = ({ key: dataKey, withStrictError: 'Child of EntityLayout must be an EntityLayout.Route', }) - .listElements() // all nodes, element data, maintain structure or not? + .listElements() // all nodes, element data, maintain structure or not? .flatMap(({ props }) => { - if (props.condition && entity && !props.condition(entity)) { + if (props.if && entity && !props.if(entity)) { return []; } diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index ba062f62f7..57efd43e74 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -27,7 +27,7 @@ import { FIELD_EXTENSION_KEY, DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS, } from '../extensions'; -import { collectComponentData, collectChildren } from '../extensions/helpers'; +import { useElementCollection } from '@backstage/core-plugin-api'; export const Router = () => { const outlet = useOutlet(); @@ -36,24 +36,13 @@ export const Router = () => { .findByComponentData({ key: FIELD_EXTENSION_WRAPPER_KEY, }) - .findByComponentData({ + .listComponentData({ key: FIELD_EXTENSION_KEY, - }) - .listComponentData(); + }); const fieldExtensions = foundExtensions.length ? foundExtensions : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; - // const fieldExtensions = useMemo(() => { - // const registeredExtensions = collectComponentData( - // collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), - // FIELD_EXTENSION_KEY, - // ); - - // return registeredExtensions.length - // ? registeredExtensions - // : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; - // }, [outlet]); return ( diff --git a/plugins/scaffolder/src/extensions/helpers.test.tsx b/plugins/scaffolder/src/extensions/helpers.test.tsx deleted file mode 100644 index ed3b74a210..0000000000 --- a/plugins/scaffolder/src/extensions/helpers.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2021 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 { collectComponentData, collectChildren } from './helpers'; -import { attachComponentData } from '@backstage/core'; - -describe('Extension Helpers', () => { - const createElementWithComponentData = ({ - type, - data, - }: { - type: string; - data: any; - }) => { - const element: React.ComponentType = () => null; - attachComponentData(element, type, data); - return element; - }; - - describe('collectChildren', () => { - it('should return the children of the component which has the correct componentData flag', () => { - const SearchElement = createElementWithComponentData({ - type: 'find.me', - data: {}, - }); - - const DontCareAboutme = createElementWithComponentData({ - type: 'dont.find.me', - data: {}, - }); - - const child1 = ( -
- hello -
- ); - - const child2 = ( -
-

Hello2

-
- ); - - const testCase = ( -
- - {child1} - {child1} - - {child2} - -

Hello!

- {child1} -
-
- ); - - const children = collectChildren(testCase, 'find.me'); - - expect(children).toEqual([[child1, child1], child2, child1]); - }); - }); - - describe('collectComponentData', () => { - it('should return the componentData for particular nodes', () => { - const componentData1 = { help: 'im something' }; - const componentData2 = { help: 'im something else' }; - - const FirstElement = createElementWithComponentData({ - type: 'find.me', - data: componentData1, - }); - - const SecondElement = createElementWithComponentData({ - type: 'dont.find.me', - data: componentData2, - }); - - const testCase = [ - , - , - , - , - ]; - const returnedData = collectComponentData(testCase, 'find.me'); - - expect(returnedData).toEqual([ - componentData1, - componentData1, - componentData1, - ]); - }); - }); -}); diff --git a/plugins/scaffolder/src/extensions/helpers.ts b/plugins/scaffolder/src/extensions/helpers.ts deleted file mode 100644 index 2eac9780e5..0000000000 --- a/plugins/scaffolder/src/extensions/helpers.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2021 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 { getComponentData } from '@backstage/core'; - -export const collectComponentData = ( - children: React.ReactNode, - componentDataKey: string, -) => { - const stack = [children]; - const found: T[] = []; - - while (stack.length) { - const current: React.ReactNode = stack.pop()!; - - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; - } - - const data = getComponentData(child, componentDataKey); - if (data) { - found.push(data); - } - - if (child.props.children) { - stack.push(child.props.children); - } - }); - } - - return found; -}; - -export const collectChildren = ( - component: React.ReactNode, - componentDataKey: string, -) => { - const stack = [component]; - const found: React.ReactNode[] = []; - - while (stack.length) { - const current: React.ReactNode = stack.pop()!; - - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; - } - - if (child.props.children) { - if (getComponentData(child, componentDataKey)) { - found.push(child.props.children); - } - stack.push(child.props.children); - } - }); - } - - return found; -};