Based on feedback from Gustaf Räntilä

Signed-off-by: bnechyporenko <bnechyporenko@bol.com>
This commit is contained in:
bnechyporenko
2022-05-13 09:10:39 +02:00
parent b17260213e
commit 7294a7ea92
21 changed files with 781 additions and 132 deletions
@@ -34,6 +34,8 @@ import {
createSubRouteRef,
createRoutableExtension,
analyticsApiRef,
createMetadataRef,
useMetadata,
} from '@backstage/core-plugin-api';
import { AppManager } from './AppManager';
import { AppComponents, AppIcons } from './types';
@@ -165,6 +167,179 @@ describe('Integration Test', () => {
const icons = {} as AppIcons;
it('should be possible to define customizable values via metadata', async () => {
type CustomPluginMetadataProps = {
pluginLabel: string;
};
const customPluginMetadataRef =
createMetadataRef<CustomPluginMetadataProps>({
id: 'custom.plugin.provider',
});
const customPluginMetadata = {
[customPluginMetadataRef.id]: {
pluginLabel: 'Default Label',
},
};
const extRouteCustomRef = createExternalRouteRef({
id: 'extRouteCustomRef',
params: ['x'],
});
const customPlugin = createPlugin({
id: 'custom-plugin',
externalRoutes: {
extRouteCustomRef,
},
metadata: [customPluginMetadata],
});
const customPluginRef = createRouteRef({ id: 'custom-ref', params: ['x'] });
const CustomPlugin = () => {
const { pluginLabel } = useMetadata<CustomPluginMetadataProps>(
customPluginMetadataRef,
);
return (
<>
<div data-testid="plugin-label">{pluginLabel}</div>
</>
);
};
const CustomComponent = customPlugin.provide(
createRoutableExtension({
name: 'CustomComponent',
component: () => Promise.resolve(() => <CustomPlugin />),
mountPoint: customPluginRef,
}),
);
const app = new AppManager({
apis: [],
defaultApis: [],
themes: [],
icons,
plugins: [customPlugin],
components,
configLoader: async () => [],
bindRoutes: ({ bind }) => {
bind(customPlugin.externalRoutes, {
extRouteCustomRef: customPluginRef,
});
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
await renderWithEffects(
<Provider>
<Router>
<Routes>
<Route path="/" element={<CustomComponent />} />
</Routes>
</Router>
</Provider>,
);
expect(await screen.getByText('Default Label')).toBeInTheDocument();
});
it('should be possible to reconfigure customizable values via metadata', async () => {
type CustomPluginMetadataProps = {
pluginLabel: string;
};
const customPluginMetadataRef =
createMetadataRef<CustomPluginMetadataProps>({
id: 'custom.plugin.provider',
});
const customPluginMetadata = {
[customPluginMetadataRef.id]: {
pluginLabel: 'Default Label',
},
};
const extRouteCustomRef = createExternalRouteRef({
id: 'extRouteCustomRef',
params: ['x'],
});
const customPlugin = createPlugin({
id: 'custom-plugin',
externalRoutes: {
extRouteCustomRef,
},
metadata: [customPluginMetadata],
});
customPlugin.reconfigure({
[customPluginMetadataRef.id]: {
pluginLabel: 'Custom Label',
},
});
const customPluginRef = createRouteRef({
id: 'custom-ref-2',
params: ['x'],
});
const RedefinedCustom = () => {
const { pluginLabel } = useMetadata<CustomPluginMetadataProps>(
customPluginMetadataRef,
);
return (
<>
<div data-testid="plugin-label">{pluginLabel}</div>
</>
);
};
const RedefinedCustomPlugin = customPlugin.provide(
createRoutableExtension({
name: 'RedefinedCustomPlugin',
component: () => Promise.resolve(() => <RedefinedCustom />),
mountPoint: customPluginRef,
}),
);
const app = new AppManager({
apis: [],
defaultApis: [],
themes: [],
icons,
plugins: [customPlugin],
components,
configLoader: async () => [],
bindRoutes: ({ bind }) => {
bind(customPlugin.externalRoutes, {
extRouteCustomRef: customPluginRef,
});
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
await renderWithEffects(
<Provider>
<Router>
<Routes>
<Route path="/" element={<RedefinedCustomPlugin />} />
</Routes>
</Router>
</Provider>,
);
expect(screen.getByText('Custom Label')).toBeInTheDocument();
});
it('runs happy paths', async () => {
const app = new AppManager({
apis: [noOpAnalyticsApi],
+38 -16
View File
@@ -47,6 +47,7 @@ import {
IdentityApi,
identityApiRef,
BackstagePlugin,
MetadataHolder,
} from '@backstage/core-plugin-api';
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
import {
@@ -80,6 +81,7 @@ import { defaultConfigLoader } from './defaultConfigLoader';
import { ApiRegistry } from '../apis/system/ApiRegistry';
import { resolveRouteBindings } from './resolveRouteBindings';
import { BackstageRouteObject } from '../routing/types';
import { MetadataProvider, MetadataRegistry } from '../metadata';
type CompatiblePlugin =
| BackstagePlugin<any, any>
@@ -173,6 +175,7 @@ export class AppManager implements BackstageApp {
private readonly appIdentityProxy = new AppIdentityProxy();
private readonly apiFactoryRegistry: ApiFactoryRegistry;
private readonly metadataRegistry: MetadataRegistry;
constructor(options: AppOptions) {
this.apis = options.apis ?? [];
@@ -184,6 +187,7 @@ export class AppManager implements BackstageApp {
this.defaultApis = options.defaultApis ?? [];
this.bindRoutes = options.bindRoutes;
this.apiFactoryRegistry = new ApiFactoryRegistry();
this.metadataRegistry = new MetadataRegistry();
}
getPlugins(): BackstagePlugin<any, any>[] {
@@ -298,23 +302,25 @@ export class AppManager implements BackstageApp {
return (
<ApiProvider apis={this.getApiHolder()}>
<AppContextProvider appContext={appContext}>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
<InternalAppContext.Provider
value={{ routeObjects: routing.objects }}
<MetadataProvider metadata={this.getMetadataHolder()}>
<AppContextProvider appContext={appContext}>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
{children}
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
</AppContextProvider>
<InternalAppContext.Provider
value={{ routeObjects: routing.objects }}
>
{children}
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
</AppContextProvider>
</MetadataProvider>
</ApiProvider>
);
};
@@ -395,6 +401,22 @@ export class AppManager implements BackstageApp {
return AppRouter;
}
private getMetadataHolder(): MetadataHolder {
for (const plugin of this.plugins) {
if (plugin.getMetadata) {
for (const entry of plugin.getMetadata()) {
for (const entryKey in entry) {
if (entry.hasOwnProperty(entryKey)) {
this.metadataRegistry.register(entryKey, entry[entryKey]);
}
}
}
}
}
return this.metadataRegistry;
}
private getApiHolder(): ApiHolder {
if (this.apiHolder) {
// Register additional plugins if they have been added.
+1
View File
@@ -21,5 +21,6 @@
*/
export * from './apis';
export * from './metadata';
export * from './app';
export * from './routing';
@@ -0,0 +1,17 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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.
*/
export * from './system';
@@ -0,0 +1,45 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { createMetadataRef } from '@backstage/core-plugin-api';
import { MetadataAggregator } from './MetadataAggregator';
import { MetadataRegistry } from './MetadataRegistry';
describe('MetadataAggregator', () => {
const apiARef = createMetadataRef<number>({ id: '1' });
const apiBRef = createMetadataRef<number>({ id: '2' });
it('should forward implementations', () => {
const holder1 = new MetadataRegistry();
holder1.register(apiARef.id, { label: 'label 1' });
const holder2 = new MetadataRegistry();
holder2.register(apiBRef.id, { label: 'label 2' });
const agg = new MetadataAggregator(holder1, holder2);
expect(agg.get(apiARef)).toEqual({ label: 'label 1' });
expect(agg.get(apiBRef)).toEqual({ label: 'label 2' });
});
it('should return the first implementation', () => {
const holder1 = new MetadataRegistry();
holder1.register(apiARef.id, { label: 'label 1' });
const holder2 = new MetadataRegistry();
holder2.register(apiARef.id, { label: 'label 2' });
const agg = new MetadataAggregator(holder1, holder2);
expect(agg.get(apiARef)).toEqual({ label: 'label 1' });
});
});
@@ -0,0 +1,39 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { MetadataRef, MetadataHolder } from '@backstage/core-plugin-api';
/**
* An MetadataHolder that queries multiple other holders from for
* an metadata implementation, returning the first one encountered..
*/
export class MetadataAggregator implements MetadataHolder {
private readonly holders: MetadataHolder[];
constructor(...holders: MetadataHolder[]) {
this.holders = holders;
}
get<T>(ref: MetadataRef<T>): T | undefined {
for (const holder of this.holders) {
const metadata = holder.get(ref);
if (metadata) {
return metadata;
}
}
return undefined;
}
}
@@ -0,0 +1,129 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 {
useMetadata,
createMetadataRef,
MetadataRef,
MetadataHolder,
} from '@backstage/core-plugin-api';
import { MetadataProvider } from './MetadataProvider';
import { MetadataRegistry } from './MetadataRegistry';
import { render } from '@testing-library/react';
import { withLogCollector } from '@backstage/test-utils';
import { useVersionedContext } from '@backstage/version-bridge';
describe('MetadataProvider', () => {
type Metadata = () => string;
const metadataRef = createMetadataRef<Metadata>({ id: 'x' });
const MyHookConsumer = () => {
const payload = useMetadata(metadataRef);
return <p>hook message: {payload}</p>;
};
it('should provide apis', () => {
const renderedHook = render(
<MetadataProvider
metadata={MetadataRegistry.from([[metadataRef, 'hello']])}
>
<MyHookConsumer />
</MetadataProvider>,
);
renderedHook.getByText('hook message: hello');
});
it('should provide nested access to apis', () => {
const aRef = createMetadataRef<string>({ id: 'a' });
const bRef = createMetadataRef<string>({ id: 'b' });
const MyComponent = () => {
const a = useMetadata(aRef);
const b = useMetadata(bRef);
return (
<div>
a={a} b={b}
</div>
);
};
const renderedHook = render(
<MetadataProvider
metadata={MetadataRegistry.from([
[aRef, 'x'],
[bRef, 'y'],
])}
>
<MetadataProvider metadata={MetadataRegistry.from([[aRef, 'z']])}>
<MyComponent />
</MetadataProvider>
</MetadataProvider>,
);
renderedHook.getByText('a=z b=y');
});
it('should error if metadata is not available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<MetadataProvider metadata={MetadataRegistry.from([])}>
<MyHookConsumer />
</MetadataProvider>,
);
}).toThrow('No implementation available for metadataRef{x}');
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No implementation available for metadataRef{x}\]/,
),
expect.stringMatching(
/^The above error occurred in the <MyHookConsumer> component/,
),
]);
});
});
describe('v1 consumer', () => {
function useMockApiV1<T>(apiRef: MetadataRef<T>): T {
const impl = useVersionedContext<{ 1: MetadataHolder }>('metadata-context')
?.atVersion(1)
?.get(apiRef);
if (!impl) {
throw new Error('no impl');
}
return impl;
}
type Api = () => string;
const apiRef = createMetadataRef<Api>({ id: 'x' });
const registry = MetadataRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumerV1 = () => {
const api = useMockApiV1(apiRef);
return <p>hook message: {api()}</p>;
};
it('should provide apis', () => {
const renderedHook = render(
<MetadataProvider metadata={registry}>
<MyHookConsumerV1 />
</MetadataProvider>,
);
renderedHook.getByText('hook message: hello');
});
});
@@ -0,0 +1,66 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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, { useContext, ReactNode, PropsWithChildren } from 'react';
import PropTypes from 'prop-types';
import { MetadataHolder } from '@backstage/core-plugin-api';
import {
createVersionedValueMap,
createVersionedContext,
} from '@backstage/version-bridge';
import { MetadataAggregator } from './MetadataAggregator';
/**
* Prop types for the MetadataProvider component.
*
* @public
*/
export type MetadataProviderProps = {
metadata: MetadataHolder;
children: ReactNode;
};
const MetadataContext = createVersionedContext<{ 1: MetadataHolder }>(
'metadata-context',
);
/**
* Provides an {@link @backstage/core-plugin-api#MetadataHolder} for consumption in
* the React tree.
*
* @public
*/
export const MetadataProvider = (
props: PropsWithChildren<MetadataProviderProps>,
) => {
const { children, metadata } = props;
const parentHolder = useContext(MetadataContext)?.atVersion(1);
const holder = parentHolder
? new MetadataAggregator(metadata, parentHolder)
: metadata;
return (
<MetadataContext.Provider
value={createVersionedValueMap({ 1: holder })}
children={children}
/>
);
};
MetadataProvider.propTypes = {
metadata: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired,
children: PropTypes.node,
};
@@ -0,0 +1,39 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { createMetadataRef } from '@backstage/core-plugin-api';
import { MetadataRegistry } from './MetadataRegistry';
describe('MetadataRegistry', () => {
const x1Ref = createMetadataRef<number>({ id: 'x1' });
const x1DuplicateRef = createMetadataRef<number>({ id: 'x1' });
const x2Ref = createMetadataRef<string>({ id: 'x2' });
it('should be created', () => {
const registry = MetadataRegistry.from([]);
expect(registry.get(x1Ref)).toBe(undefined);
});
it('should be created with metadata', () => {
const registry = MetadataRegistry.from([
[x1Ref, 3],
[x2Ref, 'y'],
]);
expect(registry.get(x1Ref)).toBe(3);
expect(registry.get(x1DuplicateRef)).toBe(3);
expect(registry.get(x2Ref)).toBe('y');
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { MetadataRef, MetadataHolder } from '@backstage/core-plugin-api';
type MetadataImpl<T = unknown> = readonly [MetadataRef<T>, T];
export class MetadataRegistry implements MetadataHolder {
private readonly registry = new Map<string, any>();
register(id: string, payload: any): boolean {
this.registry.set(id, payload);
return true;
}
get<T>(ref: MetadataRef<T>): T | undefined {
return this.registry.get(ref.id);
}
static from(entries: MetadataImpl[]) {
const registry = new MetadataRegistry();
for (const entry of entries) {
registry.register(entry[0].id, entry[1]);
}
return registry;
}
}
@@ -0,0 +1,19 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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.
*/
export { MetadataRegistry } from './MetadataRegistry';
export { MetadataProvider } from './MetadataProvider';
export { MetadataAggregator } from './MetadataAggregator';
@@ -19,7 +19,7 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { useAnalyticsContext } from '../analytics/AnalyticsContext';
import { useApp, ErrorBoundaryFallbackProps } from '../app';
import { AnyMetadata, createPlugin } from '../plugin';
import { createPlugin } from '../plugin';
import { createRouteRef } from '../routing';
import { getComponentData } from './componentData';
import {
@@ -36,20 +36,6 @@ const plugin = createPlugin({
id: 'my-plugin',
});
type Props = {
metadata?: AnyMetadata;
};
const customPlugin = createPlugin({
id: 'custom-plugin',
metadata: {
pluginLabel: 'initial label',
table: {
tableHeader: 'table header',
},
},
});
describe('extensions', () => {
it('should create a react extension with component data', () => {
const Component = () => <div />;
@@ -153,62 +139,4 @@ describe('extensions', () => {
expect(result.getByTestId('route-ref')).toHaveTextContent('some-ref');
expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy');
});
it('should allow for the plugin to define default labels via metadata', async () => {
const CustomPluginExtension = customPlugin.provide(
createReactExtension({
name: 'CustomPlugin',
component: {
sync: (props: Props) => {
return (
<>
<div data-testid="plugin-label">
{props.metadata?.pluginLabel}
</div>
</>
);
},
},
}),
);
const initialComponent = render(<CustomPluginExtension />);
expect(initialComponent.getByTestId('plugin-label')).toHaveTextContent(
'initial label',
);
});
it('should allow for the plugin to redefine default labels', async () => {
customPlugin.reconfigure({
pluginLabel: 'new label',
} as any);
const CustomPluginExtension = customPlugin.provide(
createReactExtension({
name: 'CustomPlugin',
component: {
sync: (props: Props) => {
return (
<>
<div data-testid="plugin-label">
{props.metadata?.pluginLabel}
<div data-testid="plugin-table-summary">
<h3>{props.metadata?.table.tableHeader}</h3>
</div>
</div>
</>
);
},
},
}),
);
const updatedComponent = render(<CustomPluginExtension />);
expect(updatedComponent.getByTestId('plugin-label')).toHaveTextContent(
'new label',
);
expect(
updatedComponent.getByTestId('plugin-table-summary'),
).toHaveTextContent('table header');
});
});
@@ -245,7 +245,7 @@ export function createReactExtension<
...(mountPoint && { routeRef: mountPoint.id }),
}}
>
<Component {...{ ...props, metadata: plugin.getMetadata() }} />
<Component {...props} />
</AnalyticsContext>
</PluginErrorBoundary>
</Suspense>
+1
View File
@@ -22,6 +22,7 @@
export * from './analytics';
export * from './apis';
export * from './metadata';
export * from './app';
export * from './extensions';
export * from './icons';
@@ -0,0 +1,50 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 type { MetadataRef } from './types';
export type MetadataRefConfig = {
id: string;
};
class MetadataRefImpl<T> implements MetadataRef<T> {
constructor(private readonly config: MetadataRefConfig) {}
get id(): string {
return this.config.id;
}
get T(): T {
throw new Error(`tried to read MetadataRef.T of ${this}`);
}
toString() {
return `metadataRef{${this.config.id}}`;
}
}
/**
* Creates a reference to a metadata.
*
* @param config - The descriptor of the metadata to reference.
* @returns A metadata reference.
* @public
*/
export function createMetadataRef<T>(
config: MetadataRefConfig,
): MetadataRef<T> {
return new MetadataRefImpl<T>(config);
}
@@ -0,0 +1,19 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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.
*/
export { useMetadata, useMetadataHolder } from './useMetadata';
export { createMetadataRef } from './MetadataRef';
export * from './types';
@@ -0,0 +1,34 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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.
*/
/**
* Metadata reference.
*
* @public
*/
export type MetadataRef<T> = {
id: string;
T: T;
};
/**
* Provides lookup of metadata through their {@link MetadataRef}s.
*
* @public
*/
export type MetadataHolder = {
get<T>(key: MetadataRef<T>): T | undefined;
};
@@ -0,0 +1,54 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { MetadataHolder, MetadataRef } from './types';
import { useVersionedContext } from '@backstage/version-bridge';
/**
* React hook for retrieving {@link MetadataHolder}
*
* @public
*/
export function useMetadataHolder(): MetadataHolder {
const versionedHolder = useVersionedContext<{ 1: MetadataHolder }>(
'metadata-context',
);
if (!versionedHolder) {
throw new Error('Metadata context is not available');
}
const metadataHolder = versionedHolder.atVersion(1);
if (!metadataHolder) {
throw new Error('Metadata context v1 not available');
}
return metadataHolder;
}
/**
* React hook for retrieving metadata.
*
* @param metadataRef - Reference of the metadata to use.
* @public
*/
export function useMetadata<T>(metadataRef: MetadataRef<T>): T {
const metadataHolder = useMetadataHolder();
const metadata = metadataHolder.get(metadataRef);
if (!metadata) {
throw new Error(`No implementation available for ${metadataRef}`);
}
return metadata;
}
@@ -32,35 +32,3 @@ describe('Plugin Feature Flag', () => {
).toEqual([]);
});
});
describe('Plugin metadata', () => {
it('should be able to define metadata', () => {
expect(
createPlugin({
id: 'test',
metadata: {
key: 'value',
},
}).getMetadata(),
).toEqual({
key: 'value',
});
});
it('should be able to reconfigure metadata', () => {
const plugin = createPlugin({
id: 'test',
metadata: {
key: 'original-value',
},
});
plugin.reconfigure({
key: 'modified-value',
});
expect(plugin.getMetadata()).toEqual({
key: 'modified-value',
});
});
});
@@ -23,7 +23,6 @@ import {
AnyMetadata,
PluginFeatureFlagConfig,
} from './types';
import { merge } from 'lodash';
import { AnyApiFactory } from '../apis';
/**
@@ -43,6 +42,8 @@ export class PluginImpl<
>,
) {}
private reconfiguredMetadata: Array<PluginMetadata> = [];
getId(): string {
return this.config.id;
}
@@ -55,8 +56,11 @@ export class PluginImpl<
return this.config.featureFlags?.slice() ?? [];
}
getMetadata(): PluginMetadata {
return this.config.metadata ?? ({} as PluginMetadata);
getMetadata(): Iterable<PluginMetadata> {
return [
...Array.from(this.config.metadata ?? []),
...this.reconfiguredMetadata,
];
}
get routes(): Routes {
@@ -71,9 +75,8 @@ export class PluginImpl<
return extension.expose(this);
}
reconfigure(metadata: PluginMetadata): BackstagePlugin {
this.config.metadata = merge(this.config.metadata, metadata);
return this;
reconfigure(metadata: PluginMetadata): void {
this.reconfiguredMetadata.push(metadata);
}
toString() {
+4 -4
View File
@@ -59,7 +59,7 @@ export type AnyMetadata = { [name: string]: any };
export type BackstagePlugin<
Routes extends AnyRoutes = {},
ExternalRoutes extends AnyExternalRoutes = {},
PluginMetadata extends AnyMetadata = {},
PluginMetadata extends AnyMetadata = { [name: string]: any },
> = {
getId(): string;
getApis(): Iterable<AnyApiFactory>;
@@ -67,9 +67,9 @@ export type BackstagePlugin<
* Returns all registered feature flags for this plugin.
*/
getFeatureFlags(): Iterable<PluginFeatureFlagConfig>;
getMetadata(): PluginMetadata;
getMetadata(): Iterable<PluginMetadata>;
provide<T>(extension: Extension<T>): T;
reconfigure(metadata: PluginMetadata): BackstagePlugin;
reconfigure(metadata: PluginMetadata): void;
routes: Routes;
externalRoutes: ExternalRoutes;
};
@@ -99,7 +99,7 @@ export type PluginConfig<
routes?: Routes;
externalRoutes?: ExternalRoutes;
featureFlags?: PluginFeatureFlagConfig[];
metadata?: PluginMetadata;
metadata?: Iterable<PluginMetadata>;
};
/**