Merge pull request #5993 from backstage/mob/feature-flag-component

feat: Feature Flag Component + Simplify the traversal for composability
This commit is contained in:
Ben Lambert
2021-06-17 15:20:05 +02:00
committed by GitHub
27 changed files with 974 additions and 371 deletions
+2
View File
@@ -8,6 +8,8 @@
"@backstage/cli": "^0.7.1",
"@backstage/core": "^0.7.13",
"@backstage/integration-react": "^0.1.3",
"@backstage/core-app-api": "^0.1.2",
"@backstage/core-components": "^0.1.1",
"@backstage/plugin-api-docs": "^0.5.0",
"@backstage/plugin-badges": "^0.2.2",
"@backstage/plugin-catalog": "^0.6.3",
+4 -3
View File
@@ -14,13 +14,12 @@
* limitations under the License.
*/
import { createApp, FlatRoutes } from '@backstage/core-app-api';
import {
AlertDisplay,
createApp,
FlatRoutes,
OAuthRequestDialog,
SignInPage,
} from '@backstage/core';
} from '@backstage/core-components';
import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs';
import {
CatalogEntityPage,
@@ -65,6 +64,7 @@ const app = createApp({
// Custom icon example
alert: AlarmIcon,
},
components: {
SignInPage: props => {
return (
@@ -116,6 +116,7 @@ const routes = (
/>
<Route path="/graphiql" element={<GraphiQLPage />} />
<Route path="/lighthouse" element={<LighthousePage />} />
<Route path="/api-docs" element={<ApiExplorerPage />} />
<Route path="/gcp-projects" element={<GcpProjectsPage />} />
<Route path="/newrelic" element={<NewRelicPage />} />
+12
View File
@@ -234,6 +234,18 @@ export type ErrorBoundaryFallbackProps = {
resetError: () => void;
};
// @public (undocumented)
export const FeatureFlagged: (props: FeatureFlaggedProps) => JSX.Element;
// @public (undocumented)
export type FeatureFlaggedProps = {
children: ReactNode;
} & ({
with: string;
} | {
without: string;
});
// @public (undocumented)
export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null;
+15 -3
View File
@@ -57,6 +57,7 @@ import {
} from '../extensions/traversal';
import { pluginCollector } from '../plugins/collectors';
import {
featureFlagCollector,
routeObjectCollector,
routeParentCollector,
routePathCollector,
@@ -215,7 +216,12 @@ export class PrivateAppImpl implements BackstageApp {
[],
);
const { routePaths, routeParents, routeObjects } = useMemo(() => {
const {
routePaths,
routeParents,
routeObjects,
featureFlags,
} = useMemo(() => {
const result = traverseElementTree({
root: children,
discoverers: [childDiscoverer, routeElementDiscoverer],
@@ -224,6 +230,7 @@ export class PrivateAppImpl implements BackstageApp {
routeParents: routeParentCollector,
routeObjects: routeObjectCollector,
collectedPlugins: pluginCollector,
featureFlags: featureFlagCollector,
},
});
@@ -238,7 +245,6 @@ export class PrivateAppImpl implements BackstageApp {
// Initialize APIs once all plugins are available
this.getApiHolder();
return result;
}, [children]);
@@ -273,8 +279,14 @@ export class PrivateAppImpl implements BackstageApp {
}
}
}
// Go through the featureFlags returned from the traversal and
// register those now the configApi has been loaded
for (const name of featureFlags) {
featureFlagsApi.registerFlag({ name, pluginId: '' });
}
}
}, [hasConfigApi, loadedConfig]);
}, [hasConfigApi, loadedConfig, featureFlags]);
if ('node' in loadedConfig) {
// Loading or error
+1
View File
@@ -37,6 +37,7 @@ describe('index', () => {
ConfigReader: expect.any(Function),
ErrorAlerter: expect.any(Function),
ErrorApiForwarder: expect.any(Function),
FeatureFlagged: expect.any(Function),
GithubAuth: expect.any(Function),
GitlabAuth: expect.any(Function),
GoogleAuth: expect.any(Function),
@@ -0,0 +1,102 @@
/*
* 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 { FeatureFlagged } from './FeatureFlagged';
import { render } from '@testing-library/react';
import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(featureFlagsApiRef, mockFeatureFlagsApi)}>
{children}
</ApiProvider>
);
describe('FeatureFlagged', () => {
describe('with', () => {
it('should render contents when the feature flag is enabled', async () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => true);
const { queryByText } = render(
<Wrapper>
<div>
<FeatureFlagged with="hello-flag">
<p>BACKSTAGE!</p>
</FeatureFlagged>
</div>
</Wrapper>,
);
expect(await queryByText('BACKSTAGE!')).toBeInTheDocument();
});
it('should not render contents when the feature flag is disabled', async () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => false);
const { queryByText } = render(
<Wrapper>
<div>
<FeatureFlagged with="hello-flag">
<p>BACKSTAGE!</p>
</FeatureFlagged>
</div>
</Wrapper>,
);
expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument();
});
});
describe('without', () => {
it('should not render contents when the feature flag is enabled', async () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => true);
const { queryByText } = render(
<Wrapper>
<div>
<FeatureFlagged without="hello-flag">
<p>BACKSTAGE!</p>
</FeatureFlagged>
</div>
</Wrapper>,
);
expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument();
});
it('should render contents when the feature flag is disabled', async () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => false);
const { queryByText } = render(
<Wrapper>
<div>
<FeatureFlagged without="hello-flag">
<p>BACKSTAGE!</p>
</FeatureFlagged>
</div>
</Wrapper>,
);
expect(await queryByText('BACKSTAGE!')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,39 @@
/*
* 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 {
featureFlagsApiRef,
useApi,
attachComponentData,
} from '@backstage/core-plugin-api';
import React, { ReactNode } from 'react';
export type FeatureFlaggedProps = { children: ReactNode } & (
| { with: string }
| { without: string }
);
export const FeatureFlagged = (props: FeatureFlaggedProps) => {
const { children } = props;
const featureFlagApi = useApi(featureFlagsApiRef);
const isEnabled =
'with' in props
? featureFlagApi.isActive(props.with)
: !featureFlagApi.isActive(props.without);
return <>{isEnabled ? children : null}</>;
};
attachComponentData(FeatureFlagged, 'core.featureFlagged', true);
@@ -17,25 +17,36 @@
import { render, RenderResult } from '@testing-library/react';
import React, { ReactNode } from 'react';
import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom';
import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
import { AppContext } from '../app';
import { AppContextProvider } from '../app/AppContext';
import { FlatRoutes } from './FlatRoutes';
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(featureFlagsApiRef, mockFeatureFlagsApi)}>
{children}
</ApiProvider>
);
function makeRouteRenderer(node: ReactNode) {
let rendered: RenderResult | undefined = undefined;
return (path: string) => {
const content = (
<AppContextProvider
appContext={
({
getComponents: () => ({
NotFoundErrorPage: () => <>Not Found</>,
}),
} as unknown) as AppContext
}
>
<MemoryRouter initialEntries={[path]} children={node} />
</AppContextProvider>
<Wrapper>
<AppContextProvider
appContext={
({
getComponents: () => ({
NotFoundErrorPage: () => <>Not Found</>,
}),
} as unknown) as AppContext
}
>
<MemoryRouter initialEntries={[path]} children={node} />
</AppContextProvider>
</Wrapper>
);
if (rendered) {
rendered.unmount();
@@ -14,53 +14,16 @@
* limitations under the License.
*/
import React, { ReactNode, Children, isValidElement, Fragment } from 'react';
import React, { ReactNode } from 'react';
import { useRoutes } from 'react-router-dom';
import { useApp } from '@backstage/core-plugin-api';
import { useApp, useElementFilter } 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): RouteObject[] {
return Children.toArray(childrenNode).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
const { children } = child.props;
if (child.type === Fragment) {
return createRoutesFromChildren(children);
}
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;
};
@@ -68,14 +31,41 @@ type FlatRoutesProps = {
export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => {
const app = useApp();
const { NotFoundErrorPage } = app.getComponents();
const routes = createRoutesFromChildren(props.children)
// 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 '/'
.map(obj => {
obj.path = obj.path === '/' ? '/' : `${obj.path}/*`;
return obj;
});
const routes = useElementFilter(props.children, elements =>
elements
.getElements<{ 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 '/'
.map(obj => {
obj.path = obj.path === '/' ? '/' : `${obj.path}/*`;
return obj;
}),
);
// TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop
routes.push({
@@ -19,6 +19,7 @@ import { RouteRef } from '@backstage/core-plugin-api';
import { BackstageRouteObject } from './types';
import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged';
function getMountPoint(node: ReactElement): RouteRef | undefined {
const element: ReactNode = node.props?.element;
@@ -171,3 +172,13 @@ export const routeObjectCollector = createCollector(
return parentObj;
},
);
export const featureFlagCollector = createCollector(
() => new Set<string>(),
(acc, node) => {
if (node.type === FeatureFlagged) {
const props = node.props as FeatureFlaggedProps;
acc.add('with' in props ? props.with : props.without);
}
},
);
@@ -15,3 +15,5 @@
*/
export { FlatRoutes } from './FlatRoutes';
export { FeatureFlagged } from './FeatureFlagged';
export type { FeatureFlaggedProps } from './FeatureFlagged';
+18
View File
@@ -8,6 +8,7 @@ import { BackstageTheme } from '@backstage/theme';
import { ComponentType } from 'react';
import { Config } from '@backstage/config';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { SvgIconProps } from '@material-ui/core';
@@ -227,6 +228,20 @@ export type DiscoveryApi = {
// @public (undocumented)
export const discoveryApiRef: ApiRef<DiscoveryApi>;
// @public
export interface ElementCollection {
findComponentData<T>(query: {
key: string;
}): T[];
getElements<Props extends {
[name: string]: unknown;
}>(): Array<ReactElement<Props>>;
selectByComponentData(query: {
key: string;
withStrictError?: string;
}): ElementCollection;
}
// @public
export type ErrorApi = {
post(error: Error_2, context?: ErrorContext): void;
@@ -514,6 +529,9 @@ export function useApiHolder(): ApiHolder;
// @public (undocumented)
export const useApp: () => AppContext;
// @public
export function useElementFilter<T>(node: ReactNode, filterFn: (arg: ElementCollection) => T, dependencies?: any[]): T;
// @public (undocumented)
export type UserFlags = {};
+1
View File
@@ -42,6 +42,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.7.0",
"@backstage/core-app-api": "^0.1.2",
"@backstage/test-utils": "^0.1.13",
"@backstage/test-utils-core": "^0.1.1",
"@testing-library/jest-dom": "^5.10.1",
@@ -20,3 +20,5 @@ export {
createRoutableExtension,
createComponentExtension,
} from './extensions';
export { useElementFilter } from './useElementFilter';
export type { ElementCollection } from './useElementFilter';
@@ -0,0 +1,377 @@
/*
* 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, { ReactNode } from 'react';
import { useElementFilter } from './useElementFilter';
import { renderHook } from '@testing-library/react-hooks';
import { attachComponentData } from './componentData';
import { featureFlagsApiRef } from '../apis';
import {
ApiProvider,
ApiRegistry,
LocalStorageFeatureFlags,
} from '@backstage/core-app-api';
const WRAPPING_COMPONENT_KEY = 'core.blob.testing';
const INNER_COMPONENT_KEY = 'core.blob2.testing';
const WrappingComponent = (_props: { children: ReactNode }) => null;
attachComponentData(WrappingComponent, WRAPPING_COMPONENT_KEY, {
message: 'hey! im wrapping component data',
});
const InnerComponent = () => null;
attachComponentData(InnerComponent, INNER_COMPONENT_KEY, {
message: 'hey! im the inner component',
});
const MockComponent = (_props: { children: ReactNode }) => null;
const FeatureFlagComponent = (_props: {
children: ReactNode;
with?: string;
without?: string;
}) => null;
attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true);
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(featureFlagsApiRef, mockFeatureFlagsApi)}>
{children}
</ApiProvider>
);
describe('useElementFilter', () => {
it('should select elements based on a component data key', () => {
const tree = (
<MockComponent>
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
<MockComponent>
<WrappingComponent key="second">
<WrappingComponent key="third">
<InnerComponent />
</WrappingComponent>
</WrappingComponent>
</MockComponent>
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
expect(result.current[0].key).toBe('.$.$first');
expect(result.current[1].key).toBe('.$.$second');
});
it('should find componentData', () => {
const tree = (
<MockComponent>
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements.findComponentData({ key: WRAPPING_COMPONENT_KEY }),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
expect(result.current[0]).toEqual({
message: 'hey! im wrapping component data',
});
expect(result.current[1]).toEqual({
message: 'hey! im wrapping component data',
});
});
it('can be combined to together to filter the selection', () => {
const tree = (
<MockComponent>
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.findComponentData({ key: INNER_COMPONENT_KEY }),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
expect(result.current[0]).toEqual({
message: 'hey! im the inner component',
});
expect(result.current[1]).toEqual({
message: 'hey! im the inner component',
});
});
describe('FeatureFlags', () => {
describe('with', () => {
it('should not discover deeper than the feature gate if the feature flag is disabled', () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => false);
const tree = (
<MockComponent>
<FeatureFlagComponent with="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(1);
expect(result.current[0].key).toContain('second');
});
it('should discover components behind a feature flag if the flag is enabled', () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => true);
const tree = (
<MockComponent>
<FeatureFlagComponent with="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
});
});
describe('without', () => {
it('should discover deeper than the feature gate if the feature flag is disabled', () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => false);
const tree = (
<MockComponent>
<FeatureFlagComponent without="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
});
it('should not discover components behind a feature flag if the flag is enabled', () => {
jest
.spyOn(mockFeatureFlagsApi, 'isActive')
.mockImplementation(() => true);
const tree = (
<MockComponent>
<FeatureFlagComponent without="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
<MockComponent>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</MockComponent>
<InnerComponent />
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(1);
});
});
});
it('should reject when strict mode is enabled with the correct string', () => {
const tree = (
<MockComponent>
<h1>Hello</h1>
</MockComponent>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({
key: WRAPPING_COMPONENT_KEY,
withStrictError: 'Could not find component',
})
.findComponentData({ key: INNER_COMPONENT_KEY }),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.error.message).toEqual('Could not find component');
});
it('should support fragments and text node iteration', () => {
jest.spyOn(mockFeatureFlagsApi, 'isActive').mockImplementation(() => true);
const tree = (
<>
<MockComponent>
<>
<FeatureFlagComponent with="testing-flag">
<WrappingComponent key="first">
<InnerComponent />
</WrappingComponent>
</FeatureFlagComponent>
</>
<MockComponent>
hello my name
<>
<WrappingComponent key="second">
<InnerComponent />
</WrappingComponent>
</>
</MockComponent>
is text
<InnerComponent />
</MockComponent>
</>
);
const { result } = renderHook(
props =>
useElementFilter(props.tree, elements =>
elements
.selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
.getElements(),
),
{
initialProps: { tree },
wrapper: Wrapper,
},
);
expect(result.current.length).toBe(2);
});
});
@@ -0,0 +1,188 @@
/*
* 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 {
Children,
Fragment,
isValidElement,
ReactNode,
ReactElement,
useMemo,
} from 'react';
import { getComponentData } from './componentData';
import { useApi, FeatureFlagsApi, featureFlagsApiRef } from '../apis';
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 [];
}
if (node.type === Fragment) {
return selectChildren(
node.props.children,
featureFlagsApi,
selector,
strictError,
);
}
if (getComponentData(node, 'core.featureFlagged')) {
const props = node.props as { with: string } | { without: string };
const isEnabled =
'with' in props
? featureFlagsApi.isActive(props.with)
: !featureFlagsApi.isActive(props.without);
if (isEnabled) {
return selectChildren(
node.props.children,
featureFlagsApi,
selector,
strictError,
);
}
return [];
}
if (selector === undefined || selector(node)) {
return [node];
}
if (strictError) {
throw new Error(strictError);
}
return selectChildren(
node.props.children,
featureFlagsApi,
selector,
strictError,
);
});
}
/**
* A querying interface tailored to traversing a set of selected React elements
* and extracting data.
*
* Methods prefixed with `selectBy` are used to narrow the set of selected elements.
*
* Methods prefixed with `find` return concrete data using a deep traversal of the set.
*
* Methods prefixed with `get` return concrete data using a shallow traversal of the set.
*/
export interface ElementCollection {
/**
* Narrows the set of selected components by doing a deep traversal and
* only including those that have defined component data for the given `key`.
*
* Whether an element in the tree has component data set for the given key
* is determined by whether `getComponentData` returns undefined.
*
* The traversal does not continue deeper past elements that match the criteria,
* and it also includes the root children in the selection, meaning that if the,
* of all the currently selected elements contain data for the given key, this
* method is a no-op.
*
* If `withStrictError` is set, the resulting selection must be a full match, meaning
* there may be no elements that were excluded in the selection. If the selection
* is not a clean match, an error will be throw with `withStrictError` as the message.
*/
selectByComponentData(query: {
key: string;
withStrictError?: string;
}): ElementCollection;
/**
* Finds all elements using the same criteria as `selectByComponentData`, but
* returns the actual component data of each of those elements instead.
*/
findComponentData<T>(query: { key: string }): T[];
/**
* Returns all of the elements currently selected by this collection.
*/
getElements<Props extends { [name: string]: unknown }>(): Array<
ReactElement<Props>
>;
}
class Collection implements ElementCollection {
constructor(
private readonly node: ReactNode,
private readonly featureFlagsApi: FeatureFlagsApi,
) {}
selectByComponentData(query: { key: string; withStrictError?: string }) {
const selection = selectChildren(
this.node,
this.featureFlagsApi,
node => getComponentData(node, query.key) !== undefined,
query.withStrictError,
);
return new Collection(selection, this.featureFlagsApi);
}
findComponentData<T>(query: { key: string }): T[] {
const selection = selectChildren(
this.node,
this.featureFlagsApi,
node => getComponentData(node, query.key) !== undefined,
);
return selection
.map(node => getComponentData<T>(node, query.key))
.filter((data: T | undefined): data is T => data !== undefined);
}
getElements<Props extends { [name: string]: unknown }>(): Array<
ReactElement<Props>
> {
return selectChildren(this.node, this.featureFlagsApi) as Array<
ReactElement<Props>
>;
}
}
/**
* useElementFilter is a utility that helps you narrow down and retrieve data
* from a React element tree, typically operating on the `children` property
* passed in to a component. A common use-case is to construct declarative APIs
* where a React component defines its behavior based on its children, such as
* the relationship between `Routes` and `Route` in `react-router`.
*
* The purpose of this hook is similar to `React.Children.map`, and it expands upon
* it to also handle traversal of fragments and Backstage specific things like the
* `FeatureFlagged` component.
*
* The return value of the hook is computed by the provided filter function, but
* with added memoization based on the input `node`. If further memoization
* dependencies are used in the filter function, they should be added to the
* third `dependencies` argument, just like `useMemo`, `useEffect`, etc.
*/
export function useElementFilter<T>(
node: ReactNode,
filterFn: (arg: ElementCollection) => T,
dependencies: any[] = [],
) {
const featureFlagsApi = useApi(featureFlagsApiRef);
const elements = new Collection(node, featureFlagsApi);
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => filterFn(elements), [node, ...dependencies]);
}
@@ -40,6 +40,7 @@ describe('index', () => {
useApi: expect.any(Function),
useApiHolder: expect.any(Function),
useApp: expect.any(Function),
useElementFilter: expect.any(Function),
useRouteRef: expect.any(Function),
useRouteRefParams: expect.any(Function),
withApis: expect.any(Function),