feat: writing some tests and have a nicer api now
Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com> Co-authored-by: Fredrik Adelöw <freben@users.noreply.github.com> Co-authored-by: Johan Haals <johan.haals@gmail.com> Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -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' });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -50,6 +50,8 @@ export function attachComponentData<P>(
|
||||
}
|
||||
|
||||
container.map.set(type, data);
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
export function getComponentData<T>(
|
||||
|
||||
@@ -20,4 +20,4 @@ export {
|
||||
createRoutableExtension,
|
||||
createComponentExtension,
|
||||
} from './extensions';
|
||||
export { useElementCollection } from './pennywise';
|
||||
export { useElementFilter } from './useElementFilter';
|
||||
|
||||
@@ -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 }) => (
|
||||
<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">
|
||||
<InnerComponent />
|
||||
</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',
|
||||
});
|
||||
});
|
||||
|
||||
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 flag="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 flag="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 reject with', () => {
|
||||
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',
|
||||
// errorIfNotFullMatch?
|
||||
})
|
||||
.findComponentData({ key: INNER_COMPONENT_KEY }),
|
||||
),
|
||||
{
|
||||
initialProps: { tree },
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.error.message).toEqual('Could not find component');
|
||||
});
|
||||
});
|
||||
+23
-15
@@ -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<T>(query: { key: string }): T[] {
|
||||
const selection = selectChildren(
|
||||
this.children,
|
||||
this.featureFlagsApi,
|
||||
node => Boolean(getComponentData(node, query.key)),
|
||||
findComponentData<T>(query: { key: string }): T[] {
|
||||
const selection = selectChildren(this.node, this.featureFlagsApi, node =>
|
||||
Boolean(getComponentData(node, query.key)),
|
||||
);
|
||||
return selection
|
||||
.map(node => getComponentData<T>(node, query.key))
|
||||
.filter((data: T | undefined): data is T => Boolean(data));
|
||||
}
|
||||
|
||||
listElements<Props extends { [name: string]: unknown }>(): Array<
|
||||
getElements<Props extends { [name: string]: unknown }>(): Array<
|
||||
ReactElement<Props>
|
||||
> {
|
||||
return selectChildren(this.children, this.featureFlagsApi) as Array<
|
||||
return selectChildren(this.node, this.featureFlagsApi) as Array<
|
||||
ReactElement<Props>
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
export function useElementCollection(children: ReactNode) {
|
||||
export function useElementFilter<T>(
|
||||
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]);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<FieldExtensionOptions>({
|
||||
key: FIELD_EXTENSION_KEY,
|
||||
});
|
||||
const foundExtensions = useElementFilter(outlet, elements =>
|
||||
elements
|
||||
.selectByComponentData({
|
||||
key: FIELD_EXTENSION_WRAPPER_KEY,
|
||||
})
|
||||
.select({
|
||||
key: FIELD_EXTENSION_WRAPPER_KEY,
|
||||
})
|
||||
.getComponentData<FieldExtensionOptions>({
|
||||
key: FIELD_EXTENSION_KEY,
|
||||
}),
|
||||
);
|
||||
|
||||
const fieldExtensions = foundExtensions.length
|
||||
? foundExtensions
|
||||
|
||||
Reference in New Issue
Block a user