diff --git a/.changeset/few-windows-whisper.md b/.changeset/few-windows-whisper.md
new file mode 100644
index 0000000000..565e8798a1
--- /dev/null
+++ b/.changeset/few-windows-whisper.md
@@ -0,0 +1,8 @@
+---
+'@backstage/core-app-api': patch
+'@backstage/core-plugin-api': patch
+'@backstage/plugin-catalog': patch
+'@backstage/plugin-scaffolder': patch
+---
+
+Adding `FeatureFlag` component and treating `FeatureFlags` as first class citizens to composability API
diff --git a/packages/app/package.json b/packages/app/package.json
index 13b90d1af5..9d22f6bf28 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -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",
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index b14f9a3163..cc65cd65b3 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -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 = (
/>
} />
} />
+
} />
} />
} />
diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md
index b85c9392d7..5aa709121a 100644
--- a/packages/core-app-api/api-report.md
+++ b/packages/core-app-api/api-report.md
@@ -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;
diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx
index a5ba5c4bca..ecf06955b0 100644
--- a/packages/core-app-api/src/app/App.tsx
+++ b/packages/core-app-api/src/app/App.tsx
@@ -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
diff --git a/packages/core-app-api/src/index.test.ts b/packages/core-app-api/src/index.test.ts
index bec176fda8..654c547fca 100644
--- a/packages/core-app-api/src/index.test.ts
+++ b/packages/core-app-api/src/index.test.ts
@@ -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),
diff --git a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx
new file mode 100644
index 0000000000..e20af76ae1
--- /dev/null
+++ b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx
@@ -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 }) => (
+
+ {children}
+
+);
+
+describe('FeatureFlagged', () => {
+ describe('with', () => {
+ it('should render contents when the feature flag is enabled', async () => {
+ jest
+ .spyOn(mockFeatureFlagsApi, 'isActive')
+ .mockImplementation(() => true);
+
+ const { queryByText } = render(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ expect(await queryByText('BACKSTAGE!')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/core-app-api/src/routing/FeatureFlagged.tsx b/packages/core-app-api/src/routing/FeatureFlagged.tsx
new file mode 100644
index 0000000000..40ef445f92
--- /dev/null
+++ b/packages/core-app-api/src/routing/FeatureFlagged.tsx
@@ -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);
diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.test.tsx
index a9b83d1016..3ed5d5eb3a 100644
--- a/packages/core-app-api/src/routing/FlatRoutes.test.tsx
+++ b/packages/core-app-api/src/routing/FlatRoutes.test.tsx
@@ -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 }) => (
+
+ {children}
+
+);
+
function makeRouteRenderer(node: ReactNode) {
let rendered: RenderResult | undefined = undefined;
return (path: string) => {
const content = (
- ({
- NotFoundErrorPage: () => <>Not Found>,
- }),
- } as unknown) as AppContext
- }
- >
-
-
+
+ ({
+ NotFoundErrorPage: () => <>Not Found>,
+ }),
+ } as unknown) as AppContext
+ }
+ >
+
+
+
);
if (rendered) {
rendered.unmount();
diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx
index 54ef092965..ad1f764e6c 100644
--- a/packages/core-app-api/src/routing/FlatRoutes.tsx
+++ b/packages/core-app-api/src/routing/FlatRoutes.tsx
@@ -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(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({
diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx
index e940a2cada..b9ca45f54a 100644
--- a/packages/core-app-api/src/routing/collectors.tsx
+++ b/packages/core-app-api/src/routing/collectors.tsx
@@ -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(),
+ (acc, node) => {
+ if (node.type === FeatureFlagged) {
+ const props = node.props as FeatureFlaggedProps;
+ acc.add('with' in props ? props.with : props.without);
+ }
+ },
+);
diff --git a/packages/core-app-api/src/routing/index.ts b/packages/core-app-api/src/routing/index.ts
index 7982333f4b..b37b51e919 100644
--- a/packages/core-app-api/src/routing/index.ts
+++ b/packages/core-app-api/src/routing/index.ts
@@ -15,3 +15,5 @@
*/
export { FlatRoutes } from './FlatRoutes';
+export { FeatureFlagged } from './FeatureFlagged';
+export type { FeatureFlaggedProps } from './FeatureFlagged';
diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md
index a6f196847a..90ea1f5e46 100644
--- a/packages/core-plugin-api/api-report.md
+++ b/packages/core-plugin-api/api-report.md
@@ -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;
+// @public
+export interface ElementCollection {
+ findComponentData(query: {
+ key: string;
+ }): T[];
+ getElements(): Array>;
+ 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(node: ReactNode, filterFn: (arg: ElementCollection) => T, dependencies?: any[]): T;
+
// @public (undocumented)
export type UserFlags = {};
diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json
index 816727ed9e..48706a5877 100644
--- a/packages/core-plugin-api/package.json
+++ b/packages/core-plugin-api/package.json
@@ -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",
diff --git a/packages/core-plugin-api/src/extensions/index.ts b/packages/core-plugin-api/src/extensions/index.ts
index 26a0c597b1..0ee1447b4e 100644
--- a/packages/core-plugin-api/src/extensions/index.ts
+++ b/packages/core-plugin-api/src/extensions/index.ts
@@ -20,3 +20,5 @@ export {
createRoutableExtension,
createComponentExtension,
} from './extensions';
+export { useElementFilter } from './useElementFilter';
+export type { ElementCollection } from './useElementFilter';
diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx
new file mode 100644
index 0000000000..73fbb9e5a3
--- /dev/null
+++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx
@@ -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 }) => (
+
+ {children}
+
+);
+
+describe('useElementFilter', () => {
+ it('should select elements based on a component data key', () => {
+ const tree = (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ 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 = (
+
+
+
+
+
+
+
+
+
+
+ );
+
+ 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 = (
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ 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 = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ 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 = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ 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 = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ 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 = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ 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 = (
+
+ Hello
+
+ );
+
+ 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 = (
+ <>
+
+ <>
+
+
+
+
+
+ >
+
+ hello my name
+ <>
+
+
+
+ >
+
+ is text
+
+
+ >
+ );
+
+ 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);
+ });
+});
diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.tsx
new file mode 100644
index 0000000000..0549472ab2
--- /dev/null
+++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx
@@ -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) => boolean,
+ strictError?: string,
+): Array> {
+ 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(query: { key: string }): T[];
+
+ /**
+ * Returns all of the elements currently selected by this collection.
+ */
+ getElements(): Array<
+ ReactElement
+ >;
+}
+
+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(query: { key: string }): T[] {
+ const selection = selectChildren(
+ this.node,
+ this.featureFlagsApi,
+ node => getComponentData(node, query.key) !== undefined,
+ );
+ return selection
+ .map(node => getComponentData(node, query.key))
+ .filter((data: T | undefined): data is T => data !== undefined);
+ }
+
+ getElements(): Array<
+ ReactElement
+ > {
+ return selectChildren(this.node, this.featureFlagsApi) as Array<
+ ReactElement
+ >;
+ }
+}
+
+/**
+ * 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(
+ 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]);
+}
diff --git a/packages/core-plugin-api/src/index.test.ts b/packages/core-plugin-api/src/index.test.ts
index 9414a6b730..5d29ead619 100644
--- a/packages/core-plugin-api/src/index.test.ts
+++ b/packages/core-plugin-api/src/index.test.ts
@@ -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),
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index 4e35cc7618..0af476b95a 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -33,6 +33,7 @@
"@backstage/catalog-client": "^0.3.13",
"@backstage/catalog-model": "^0.8.3",
"@backstage/core": "^0.7.13",
+ "@backstage/core-plugin-api": "^0.1.2",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.6",
"@backstage/integration-react": "^0.1.3",
@@ -54,6 +55,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.7.1",
+ "@backstage/core-app-api": "^0.1.2",
"@backstage/dev-utils": "^0.1.17",
"@backstage/test-utils": "^0.1.13",
"@testing-library/jest-dom": "^5.10.1",
diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
index dfd27631f8..aceb1da6f2 100644
--- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
+++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
@@ -29,6 +29,7 @@ import {
Progress,
RoutedTabs,
} from '@backstage/core';
+import { useElementFilter } from '@backstage/core-plugin-api';
import {
EntityContext,
EntityRefLinks,
@@ -37,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';
@@ -58,46 +52,14 @@ type SubRoute = {
tabProps?: TabProps;
};
+const dataKey = 'plugin.catalog.entityLayoutRoute';
+
const Route: (props: SubRoute) => null = () => null;
+attachComponentData(Route, dataKey, true);
// This causes all mount points that are discovered within this route to use the path of the route itself
attachComponentData(Route, 'core.gatherMountPoints', true);
-function createSubRoutesFromChildren(
- childrenProps: React.ReactNode,
- entity: Entity | undefined,
-): SubRoute[] {
- // Directly comparing child.type with Route will not work with in
- // combination with react-hot-loader in storybook
- // https://github.com/gaearon/react-hot-loader/issues/304
- const routeType = (
-
-
-
- ).type;
-
- return Children.toArray(childrenProps).flatMap(child => {
- if (!isValidElement(child)) {
- return [];
- }
-
- if (child.type === Fragment) {
- return createSubRoutesFromChildren(child.props.children, entity);
- }
-
- if (child.type !== routeType) {
- throw new Error('Child of EntityLayout must be an EntityLayout.Route');
- }
-
- const { path, title, children, if: condition, tabProps } = child.props;
- if (condition && entity && !condition(entity)) {
- return [];
- }
-
- return [{ path, title, children, tabProps }];
- });
-}
-
const EntityLayoutTitle = ({
entity,
title,
@@ -195,7 +157,29 @@ export const EntityLayout = ({
const { kind, namespace, name } = useEntityCompoundName();
const { entity, loading, error } = useContext(EntityContext);
- const routes = createSubRoutesFromChildren(children, entity);
+ const routes = useElementFilter(children, elements =>
+ elements
+ .selectByComponentData({
+ key: dataKey,
+ withStrictError: 'Child of EntityLayout must be an EntityLayout.Route',
+ })
+ .getElements() // all nodes, element data, maintain structure or not?
+ .flatMap(({ props }) => {
+ if (props.if && entity && !props.if(entity)) {
+ return [];
+ }
+
+ return [
+ {
+ path: props.path,
+ title: props.title,
+ children: props.children,
+ tabProps: props.tabProps,
+ },
+ ];
+ }),
+ );
+
const { headerTitle, headerType } = headerProps(
kind,
namespace,
diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx
index 20b9aaba4f..5b21266d44 100644
--- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx
+++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx
@@ -20,6 +20,19 @@ import { render } from '@testing-library/react';
import React from 'react';
import { isKind } from './conditions';
import { EntitySwitch } from './EntitySwitch';
+import { featureFlagsApiRef } from '@backstage/core-plugin-api';
+import {
+ LocalStorageFeatureFlags,
+ ApiProvider,
+ ApiRegistry,
+} from '@backstage/core-app-api';
+
+const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
+const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+);
describe('EntitySwitch', () => {
it('should switch child when entity switches', () => {
@@ -32,15 +45,17 @@ describe('EntitySwitch', () => {
);
const rendered = render(
-
- {content}
- ,
+
+
+ {content}
+
+ ,
);
expect(rendered.queryByText('A')).toBeInTheDocument();
@@ -48,15 +63,17 @@ describe('EntitySwitch', () => {
expect(rendered.queryByText('C')).not.toBeInTheDocument();
rendered.rerender(
-
- {content}
- ,
+
+
+ {content}
+
+ ,
);
expect(rendered.queryByText('A')).not.toBeInTheDocument();
@@ -64,15 +81,17 @@ describe('EntitySwitch', () => {
expect(rendered.queryByText('C')).not.toBeInTheDocument();
rendered.rerender(
-
- {content}
- ,
+
+
+ {content}
+
+ ,
);
expect(rendered.queryByText('A')).not.toBeInTheDocument();
@@ -88,24 +107,28 @@ describe('EntitySwitch', () => {
};
const rendered = render(
-
-
-
-
-
- ,
+
+
+
+
+
+
+
+ ,
);
expect(rendered.queryByText('A')).toBeInTheDocument();
expect(rendered.queryByText('B')).not.toBeInTheDocument();
rendered.rerender(
-
-
-
-
-
- ,
+
+
+
+
+
+
+
+ ,
);
expect(rendered.queryByText('A')).not.toBeInTheDocument();
diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx
index a3ede42288..4b12a7249b 100644
--- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx
+++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx
@@ -16,49 +16,40 @@
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
+import { PropsWithChildren, ReactNode } from 'react';
import {
- Children,
- Fragment,
- isValidElement,
- PropsWithChildren,
- ReactNode,
- useMemo,
-} from 'react';
+ attachComponentData,
+ useElementFilter,
+} from '@backstage/core-plugin-api';
+
+const ENTITY_SWITCH_KEY = 'core.backstage.entitySwitch';
const EntitySwitchCase = (_: {
if?: (entity: Entity) => boolean;
children: ReactNode;
}) => null;
+attachComponentData(EntitySwitchCase, ENTITY_SWITCH_KEY, true);
+
type SwitchCase = {
if?: (entity: Entity) => boolean;
children: JSX.Element;
};
-function createSwitchCasesFromChildren(childrenNode: ReactNode): SwitchCase[] {
- return Children.toArray(childrenNode).flatMap(child => {
- if (!isValidElement(child)) {
- return [];
- }
-
- if (child.type === Fragment) {
- return createSwitchCasesFromChildren(child.props.children);
- }
-
- if (child.type !== EntitySwitchCase) {
- throw new Error(`Child of EntitySwitch is not an EntitySwitch.Case`);
- }
-
- const { if: condition, children } = child.props;
- return [{ if: condition, children }];
- });
-}
-
export const EntitySwitch = ({ children }: PropsWithChildren<{}>) => {
const { entity } = useEntity();
- const switchCases = useMemo(() => createSwitchCasesFromChildren(children), [
- children,
- ]);
+ const switchCases = useElementFilter(children, collection =>
+ collection
+ .selectByComponentData({
+ key: ENTITY_SWITCH_KEY,
+ withStrictError: 'Child of EntitySwitch is not an EntitySwitch.Case',
+ })
+ .getElements()
+ .flatMap((element: React.ReactElement) => {
+ const { if: condition, children: elementsChildren } = element.props;
+ return [{ if: condition, children: elementsChildren }];
+ }),
+ );
const matchingCase = switchCases.find(switchCase =>
switchCase.if ? switchCase.if(entity) : true,
diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json
index 69d71e7966..120cd7e297 100644
--- a/plugins/scaffolder/package.json
+++ b/plugins/scaffolder/package.json
@@ -33,11 +33,12 @@
"@backstage/catalog-client": "^0.3.13",
"@backstage/catalog-model": "^0.8.2",
"@backstage/config": "^0.1.5",
- "@backstage/errors": "^0.1.1",
"@backstage/core": "^0.7.12",
"@backstage/integration": "^0.5.6",
"@backstage/integration-react": "^0.1.3",
"@backstage/plugin-catalog-react": "^0.2.2",
+ "@backstage/core-plugin-api": "^0.1.2",
+ "@backstage/errors": "^0.1.1",
"@backstage/theme": "^0.2.8",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
@@ -46,10 +47,10 @@
"@rjsf/material-ui": "^2.4.0",
"@types/react": "^16.9",
"classnames": "^2.2.6",
- "json-schema": "^0.3.0",
"git-url-parse": "^11.4.4",
"humanize-duration": "^3.25.1",
"immer": "^9.0.1",
+ "json-schema": "^0.3.0",
"luxon": "^1.25.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
@@ -66,9 +67,9 @@
"@backstage/test-utils": "^0.1.13",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
+ "@testing-library/react-hooks": "^3.3.0",
"@testing-library/user-event": "^13.1.8",
"@types/humanize-duration": "^3.18.1",
- "@testing-library/react-hooks": "^3.3.0",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx
index 5ac663c7ea..ef0dd4082e 100644
--- a/plugins/scaffolder/src/components/Router.tsx
+++ b/plugins/scaffolder/src/components/Router.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import React, { useMemo } from 'react';
+import React from 'react';
import { Routes, Route, useOutlet } from 'react-router';
import { ScaffolderPage } from './ScaffolderPage';
import { TemplatePage } from './TemplatePage';
@@ -27,21 +27,24 @@ import {
FIELD_EXTENSION_KEY,
DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS,
} from '../extensions';
-import { collectComponentData, collectChildren } from '../extensions/helpers';
+import { useElementFilter } from '@backstage/core-plugin-api';
export const Router = () => {
const outlet = useOutlet();
- const fieldExtensions = useMemo(() => {
- const registeredExtensions = collectComponentData(
- collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(),
- FIELD_EXTENSION_KEY,
- );
+ const foundExtensions = useElementFilter(outlet, elements =>
+ elements
+ .selectByComponentData({
+ key: FIELD_EXTENSION_WRAPPER_KEY,
+ })
+ .findComponentData({
+ key: FIELD_EXTENSION_KEY,
+ }),
+ );
- return registeredExtensions.length
- ? registeredExtensions
- : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS;
- }, [outlet]);
+ const fieldExtensions = foundExtensions.length
+ ? foundExtensions
+ : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS;
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 = (
-
- );
-
- 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;
-};
diff --git a/yarn.lock b/yarn.lock
index cdfa538e80..208ad9e952 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1394,6 +1394,7 @@
"@backstage/catalog-client" "^0.3.13"
"@backstage/catalog-model" "^0.8.2"
"@backstage/core" "^0.7.12"
+ "@backstage/core-plugin-api" "^0.1.2"
"@backstage/errors" "^0.1.1"
"@backstage/integration" "^0.5.6"
"@backstage/integration-react" "^0.1.3"