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);
}