Based on feedback from Gustaf Räntilä
Signed-off-by: bnechyporenko <bnechyporenko@bol.com>
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user