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:
blam
2021-06-10 17:01:26 +02:00
parent 78ccf9ea98
commit 969c6750af
8 changed files with 295 additions and 29 deletions
@@ -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' });
+1
View File
@@ -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');
});
});
@@ -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]);
}
+4 -3
View File
@@ -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 -9
View File
@@ -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