diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx index 467145acfc..1b518ad0a4 100644 --- a/packages/core-app-api/src/app/AppManager.test.tsx +++ b/packages/core-app-api/src/app/AppManager.test.tsx @@ -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({ + 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( + customPluginMetadataRef, + ); + + return ( + <> +
{pluginLabel}
+ + ); + }; + + const CustomComponent = customPlugin.provide( + createRoutableExtension({ + name: 'CustomComponent', + component: () => Promise.resolve(() => ), + 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( + + + + } /> + + + , + ); + + 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({ + 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( + customPluginMetadataRef, + ); + + return ( + <> +
{pluginLabel}
+ + ); + }; + + const RedefinedCustomPlugin = customPlugin.provide( + createRoutableExtension({ + name: 'RedefinedCustomPlugin', + component: () => Promise.resolve(() => ), + 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( + + + + } /> + + + , + ); + + expect(screen.getByText('Custom Label')).toBeInTheDocument(); + }); + it('runs happy paths', async () => { const app = new AppManager({ apis: [noOpAnalyticsApi], diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 75bab5c2df..20b8161a8b 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -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 @@ -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[] { @@ -298,23 +302,25 @@ export class AppManager implements BackstageApp { return ( - - - - + + + - {children} - - - - + + {children} + + + + + ); }; @@ -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. diff --git a/packages/core-app-api/src/index.ts b/packages/core-app-api/src/index.ts index a5e6b649e9..d7b24dbf28 100644 --- a/packages/core-app-api/src/index.ts +++ b/packages/core-app-api/src/index.ts @@ -21,5 +21,6 @@ */ export * from './apis'; +export * from './metadata'; export * from './app'; export * from './routing'; diff --git a/packages/core-app-api/src/metadata/index.ts b/packages/core-app-api/src/metadata/index.ts new file mode 100644 index 0000000000..59aa474fee --- /dev/null +++ b/packages/core-app-api/src/metadata/index.ts @@ -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'; diff --git a/packages/core-app-api/src/metadata/system/MetadataAggregator.test.ts b/packages/core-app-api/src/metadata/system/MetadataAggregator.test.ts new file mode 100644 index 0000000000..ba6aa05c6f --- /dev/null +++ b/packages/core-app-api/src/metadata/system/MetadataAggregator.test.ts @@ -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({ id: '1' }); + const apiBRef = createMetadataRef({ 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' }); + }); +}); diff --git a/packages/core-app-api/src/metadata/system/MetadataAggregator.ts b/packages/core-app-api/src/metadata/system/MetadataAggregator.ts new file mode 100644 index 0000000000..ebc26961c1 --- /dev/null +++ b/packages/core-app-api/src/metadata/system/MetadataAggregator.ts @@ -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(ref: MetadataRef): T | undefined { + for (const holder of this.holders) { + const metadata = holder.get(ref); + if (metadata) { + return metadata; + } + } + return undefined; + } +} diff --git a/packages/core-app-api/src/metadata/system/MetadataProvider.test.tsx b/packages/core-app-api/src/metadata/system/MetadataProvider.test.tsx new file mode 100644 index 0000000000..dfbd96eb31 --- /dev/null +++ b/packages/core-app-api/src/metadata/system/MetadataProvider.test.tsx @@ -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({ id: 'x' }); + + const MyHookConsumer = () => { + const payload = useMetadata(metadataRef); + return

hook message: {payload}

; + }; + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + }); + + it('should provide nested access to apis', () => { + const aRef = createMetadataRef({ id: 'a' }); + const bRef = createMetadataRef({ id: 'b' }); + + const MyComponent = () => { + const a = useMetadata(aRef); + const b = useMetadata(bRef); + return ( +
+ a={a} b={b} +
+ ); + }; + + const renderedHook = render( + + + + + , + ); + renderedHook.getByText('a=z b=y'); + }); + + it('should error if metadata is not available', () => { + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + , + ); + }).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 component/, + ), + ]); + }); +}); + +describe('v1 consumer', () => { + function useMockApiV1(apiRef: MetadataRef): 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({ id: 'x' }); + const registry = MetadataRegistry.from([[apiRef, () => 'hello']]); + + const MyHookConsumerV1 = () => { + const api = useMockApiV1(apiRef); + return

hook message: {api()}

; + }; + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + }); +}); diff --git a/packages/core-app-api/src/metadata/system/MetadataProvider.tsx b/packages/core-app-api/src/metadata/system/MetadataProvider.tsx new file mode 100644 index 0000000000..a072f0a84a --- /dev/null +++ b/packages/core-app-api/src/metadata/system/MetadataProvider.tsx @@ -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, +) => { + const { children, metadata } = props; + const parentHolder = useContext(MetadataContext)?.atVersion(1); + const holder = parentHolder + ? new MetadataAggregator(metadata, parentHolder) + : metadata; + + return ( + + ); +}; + +MetadataProvider.propTypes = { + metadata: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired, + children: PropTypes.node, +}; diff --git a/packages/core-app-api/src/metadata/system/MetadataRegistry.test.ts b/packages/core-app-api/src/metadata/system/MetadataRegistry.test.ts new file mode 100644 index 0000000000..4acac8565a --- /dev/null +++ b/packages/core-app-api/src/metadata/system/MetadataRegistry.test.ts @@ -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({ id: 'x1' }); + const x1DuplicateRef = createMetadataRef({ id: 'x1' }); + const x2Ref = createMetadataRef({ 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'); + }); +}); diff --git a/packages/core-app-api/src/metadata/system/MetadataRegistry.ts b/packages/core-app-api/src/metadata/system/MetadataRegistry.ts new file mode 100644 index 0000000000..053a52b5f6 --- /dev/null +++ b/packages/core-app-api/src/metadata/system/MetadataRegistry.ts @@ -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 = readonly [MetadataRef, T]; + +export class MetadataRegistry implements MetadataHolder { + private readonly registry = new Map(); + + register(id: string, payload: any): boolean { + this.registry.set(id, payload); + return true; + } + + get(ref: MetadataRef): 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; + } +} diff --git a/packages/core-app-api/src/metadata/system/index.ts b/packages/core-app-api/src/metadata/system/index.ts new file mode 100644 index 0000000000..faa7c814c8 --- /dev/null +++ b/packages/core-app-api/src/metadata/system/index.ts @@ -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'; diff --git a/packages/core-plugin-api/src/extensions/extensions.test.tsx b/packages/core-plugin-api/src/extensions/extensions.test.tsx index 28d84cae3e..dcc92d9d47 100644 --- a/packages/core-plugin-api/src/extensions/extensions.test.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.test.tsx @@ -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 = () =>
; @@ -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 ( - <> -
- {props.metadata?.pluginLabel} -
- - ); - }, - }, - }), - ); - - const initialComponent = render(); - 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 ( - <> -
- {props.metadata?.pluginLabel} -
-

{props.metadata?.table.tableHeader}

-
-
- - ); - }, - }, - }), - ); - - const updatedComponent = render(); - expect(updatedComponent.getByTestId('plugin-label')).toHaveTextContent( - 'new label', - ); - expect( - updatedComponent.getByTestId('plugin-table-summary'), - ).toHaveTextContent('table header'); - }); }); diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index 8f1efe3b65..19da082ee1 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -245,7 +245,7 @@ export function createReactExtension< ...(mountPoint && { routeRef: mountPoint.id }), }} > - + diff --git a/packages/core-plugin-api/src/index.ts b/packages/core-plugin-api/src/index.ts index 30fc35b62a..172f00295b 100644 --- a/packages/core-plugin-api/src/index.ts +++ b/packages/core-plugin-api/src/index.ts @@ -22,6 +22,7 @@ export * from './analytics'; export * from './apis'; +export * from './metadata'; export * from './app'; export * from './extensions'; export * from './icons'; diff --git a/packages/core-plugin-api/src/metadata/MetadataRef.ts b/packages/core-plugin-api/src/metadata/MetadataRef.ts new file mode 100644 index 0000000000..f261fb5352 --- /dev/null +++ b/packages/core-plugin-api/src/metadata/MetadataRef.ts @@ -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 implements MetadataRef { + 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( + config: MetadataRefConfig, +): MetadataRef { + return new MetadataRefImpl(config); +} diff --git a/packages/core-plugin-api/src/metadata/index.ts b/packages/core-plugin-api/src/metadata/index.ts new file mode 100644 index 0000000000..29d5532edd --- /dev/null +++ b/packages/core-plugin-api/src/metadata/index.ts @@ -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'; diff --git a/packages/core-plugin-api/src/metadata/types.ts b/packages/core-plugin-api/src/metadata/types.ts new file mode 100644 index 0000000000..5b721b5b55 --- /dev/null +++ b/packages/core-plugin-api/src/metadata/types.ts @@ -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 = { + id: string; + T: T; +}; + +/** + * Provides lookup of metadata through their {@link MetadataRef}s. + * + * @public + */ +export type MetadataHolder = { + get(key: MetadataRef): T | undefined; +}; diff --git a/packages/core-plugin-api/src/metadata/useMetadata.tsx b/packages/core-plugin-api/src/metadata/useMetadata.tsx new file mode 100644 index 0000000000..99ce68f1e4 --- /dev/null +++ b/packages/core-plugin-api/src/metadata/useMetadata.tsx @@ -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(metadataRef: MetadataRef): T { + const metadataHolder = useMetadataHolder(); + + const metadata = metadataHolder.get(metadataRef); + if (!metadata) { + throw new Error(`No implementation available for ${metadataRef}`); + } + return metadata; +} diff --git a/packages/core-plugin-api/src/plugin/Plugin.test.tsx b/packages/core-plugin-api/src/plugin/Plugin.test.tsx index 26c704517f..b66235e210 100644 --- a/packages/core-plugin-api/src/plugin/Plugin.test.tsx +++ b/packages/core-plugin-api/src/plugin/Plugin.test.tsx @@ -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', - }); - }); -}); diff --git a/packages/core-plugin-api/src/plugin/Plugin.tsx b/packages/core-plugin-api/src/plugin/Plugin.tsx index 29eacde6ae..999d154166 100644 --- a/packages/core-plugin-api/src/plugin/Plugin.tsx +++ b/packages/core-plugin-api/src/plugin/Plugin.tsx @@ -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 = []; + 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 { + 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() { diff --git a/packages/core-plugin-api/src/plugin/types.ts b/packages/core-plugin-api/src/plugin/types.ts index a640013c47..8aadac36bc 100644 --- a/packages/core-plugin-api/src/plugin/types.ts +++ b/packages/core-plugin-api/src/plugin/types.ts @@ -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; @@ -67,9 +67,9 @@ export type BackstagePlugin< * Returns all registered feature flags for this plugin. */ getFeatureFlags(): Iterable; - getMetadata(): PluginMetadata; + getMetadata(): Iterable; provide(extension: Extension): 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; }; /**