feat: replace implementations of children introspection with our new thing

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Fredrik Adelöw <freben@users.noreply.github.com>
Co-authored-by: Johan Haals <johan.haals@gmail.com>

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2021-06-10 11:54:14 +02:00
parent d7f0b0bed6
commit 78ccf9ea98
9 changed files with 121 additions and 348 deletions
@@ -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' });
@@ -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<RouteObject>(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 '/'
+2 -1
View File
@@ -15,4 +15,5 @@
*/
export { FlatRoutes } from './FlatRoutes';
export { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged';
export { FeatureFlagged } from './FeatureFlagged';
export type { FeatureFlaggedProps } from './FeatureFlagged';
@@ -20,3 +20,4 @@ export {
createRoutableExtension,
createComponentExtension,
} from './extensions';
export { useElementCollection } from './pennywise';
@@ -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 = <T>(
children: React.ReactNode,
componentDataKey: string,
) => {
const stack = [children];
const found: T[] = [];
function selectChildren(
rootNode: ReactNode,
featureFlagsApi: FeatureFlagsApi,
selector?: (element: ReactElement<unknown>) => boolean,
strictError?: string,
): Array<ReactElement<unknown>> {
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<T>(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<T>(query: { key: string }): T[] {
const selection = selectChildren(
this.children,
this.featureFlagsApi,
node => Boolean(getComponentData(node, query.key)),
);
return selection
.map(node => getComponentData<T>(node, query.key))
.filter((data: T | undefined): data is T => Boolean(data));
}
listElements<Props extends { [name: string]: unknown }>(): Array<
ReactElement<Props>
> {
return selectChildren(this.children, this.featureFlagsApi) as Array<
ReactElement<Props>
>;
}
listComponentData<T>(query: { key: string }): T[] {}
// listElements
}
export function useElementCollection(children: ReactNode) {
return new ElementCollection(children);
const featureFlagsApi = useApi(featureFlagsApiRef);
return new ElementCollection(children, featureFlagsApi);
}
@@ -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<SubRoute>() // 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 [];
}
+3 -14
View File
@@ -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<FieldExtensionOptions>({
key: FIELD_EXTENSION_KEY,
})
.listComponentData<FieldExtensionOptions>();
});
const fieldExtensions = foundExtensions.length
? foundExtensions
: DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS;
// const fieldExtensions = useMemo(() => {
// const registeredExtensions = collectComponentData<FieldExtensionOptions>(
// collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(),
// FIELD_EXTENSION_KEY,
// );
// return registeredExtensions.length
// ? registeredExtensions
// : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS;
// }, [outlet]);
return (
<Routes>
@@ -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 = (
<div>
<b>hello</b>
</div>
);
const child2 = (
<div>
<p>Hello2</p>
</div>
);
const testCase = (
<div>
<SearchElement>
{child1}
{child1}
</SearchElement>
<SearchElement>{child2}</SearchElement>
<DontCareAboutme>
<p>Hello!</p>
<SearchElement>{child1}</SearchElement>
</DontCareAboutme>
</div>
);
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 = [
<FirstElement />,
<FirstElement />,
<SecondElement />,
<FirstElement />,
];
const returnedData = collectComponentData(testCase, 'find.me');
expect(returnedData).toEqual([
componentData1,
componentData1,
componentData1,
]);
});
});
});
@@ -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 = <T>(
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<T>(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;
};