diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx
index 4441d4abf3..498d1dd9fc 100644
--- a/packages/core-api/src/routing/collectors.test.tsx
+++ b/packages/core-api/src/routing/collectors.test.tsx
@@ -38,7 +38,7 @@ const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
const plugin = createPlugin({ id: 'my-plugin' });
-/const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' });
+const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' });
const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' });
const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' });
const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' });
diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json
index 816727ed9e..b1d53e17c0 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.1",
"@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/componentData.tsx b/packages/core-plugin-api/src/extensions/componentData.tsx
index f835ad38a8..059b8e1304 100644
--- a/packages/core-plugin-api/src/extensions/componentData.tsx
+++ b/packages/core-plugin-api/src/extensions/componentData.tsx
@@ -50,6 +50,8 @@ export function attachComponentData
(
}
container.map.set(type, data);
+
+ return component;
}
export function getComponentData(
diff --git a/packages/core-plugin-api/src/extensions/index.ts b/packages/core-plugin-api/src/extensions/index.ts
index 8d2d6872d9..71373db1a3 100644
--- a/packages/core-plugin-api/src/extensions/index.ts
+++ b/packages/core-plugin-api/src/extensions/index.ts
@@ -20,4 +20,4 @@ export {
createRoutableExtension,
createComponentExtension,
} from './extensions';
-export { useElementCollection } from './pennywise';
+export { useElementFilter } 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..4725de66c2
--- /dev/null
+++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx
@@ -0,0 +1,249 @@
+/*
+ * 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; flag: 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',
+ });
+ });
+
+ 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);
+ });
+
+ it('should reject with', () => {
+ const tree = (
+
+ Hello
+
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements
+ .selectByComponentData({
+ key: WRAPPING_COMPONENT_KEY,
+ withStrictError: 'Could not find component',
+ // errorIfNotFullMatch?
+ })
+ .findComponentData({ key: INNER_COMPONENT_KEY }),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.error.message).toEqual('Could not find component');
+ });
+});
diff --git a/packages/core-plugin-api/src/extensions/pennywise.ts b/packages/core-plugin-api/src/extensions/useElementFilter.tsx
similarity index 72%
rename from packages/core-plugin-api/src/extensions/pennywise.ts
rename to packages/core-plugin-api/src/extensions/useElementFilter.tsx
index d6d92a267e..9ed070fd6c 100644
--- a/packages/core-plugin-api/src/extensions/pennywise.ts
+++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx
@@ -19,6 +19,7 @@ import {
isValidElement,
ReactNode,
ReactElement,
+ useMemo,
} from 'react';
import { getComponentData } from './componentData';
import { useApi, FeatureFlagsApi, featureFlagsApiRef } from '../apis';
@@ -34,10 +35,13 @@ function selectChildren(
return [];
}
- const { children } = node.props;
-
if (node.type === Fragment) {
- return selectChildren(children, featureFlagsApi, selector, strictError);
+ return selectChildren(
+ node.props.children,
+ featureFlagsApi,
+ selector,
+ strictError,
+ );
}
if (getComponentData(node, 'core.featureFlagged')) {
@@ -72,13 +76,13 @@ function selectChildren(
class ElementCollection {
constructor(
- private readonly children: ReactNode,
+ private readonly node: ReactNode,
private readonly featureFlagsApi: FeatureFlagsApi,
) {}
- findByComponentData(query: { key: string; withStrictError?: string }) {
+ selectByComponentData(query: { key: string; withStrictError?: string }) {
const selection = selectChildren(
- this.children,
+ this.node,
this.featureFlagsApi,
node => Boolean(getComponentData(node, query.key)),
query.withStrictError,
@@ -86,27 +90,31 @@ class ElementCollection {
return new ElementCollection(selection, this.featureFlagsApi);
}
- listComponentData(query: { key: string }): T[] {
- const selection = selectChildren(
- this.children,
- this.featureFlagsApi,
- node => Boolean(getComponentData(node, query.key)),
+ findComponentData(query: { key: string }): T[] {
+ const selection = selectChildren(this.node, this.featureFlagsApi, node =>
+ Boolean(getComponentData(node, query.key)),
);
return selection
.map(node => getComponentData(node, query.key))
.filter((data: T | undefined): data is T => Boolean(data));
}
- listElements(): Array<
+ getElements(): Array<
ReactElement
> {
- return selectChildren(this.children, this.featureFlagsApi) as Array<
+ return selectChildren(this.node, this.featureFlagsApi) as Array<
ReactElement
>;
}
}
-export function useElementCollection(children: ReactNode) {
+export function useElementFilter(
+ node: ReactNode,
+ filterFn: (arg: ElementCollection) => T,
+ dependencies: any[] = [],
+) {
const featureFlagsApi = useApi(featureFlagsApiRef);
- return new ElementCollection(children, featureFlagsApi);
+ const elements = new ElementCollection(node, featureFlagsApi);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ return useMemo(() => filterFn(elements), [node, ...dependencies]);
}
diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json
index a7541a5ddc..3cb046966a 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.1",
+ "@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 57efd43e74..e72b9a9a1e 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,18 +27,23 @@ import {
FIELD_EXTENSION_KEY,
DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS,
} from '../extensions';
-import { useElementCollection } from '@backstage/core-plugin-api';
+import { useElementFilter } from '@backstage/core-plugin-api';
export const Router = () => {
const outlet = useOutlet();
- const foundExtensions = useElementCollection(outlet)
- .findByComponentData({
- key: FIELD_EXTENSION_WRAPPER_KEY,
- })
- .listComponentData({
- key: FIELD_EXTENSION_KEY,
- });
+ const foundExtensions = useElementFilter(outlet, elements =>
+ elements
+ .selectByComponentData({
+ key: FIELD_EXTENSION_WRAPPER_KEY,
+ })
+ .select({
+ key: FIELD_EXTENSION_WRAPPER_KEY,
+ })
+ .getComponentData({
+ key: FIELD_EXTENSION_KEY,
+ }),
+ );
const fieldExtensions = foundExtensions.length
? foundExtensions