Merge pull request #5993 from backstage/mob/feature-flag-component
feat: Feature Flag Component + Simplify the traversal for composability
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user