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