diff --git a/.changeset/silly-trainers-clean.md b/.changeset/silly-trainers-clean.md new file mode 100644 index 0000000000..b4690a0e8f --- /dev/null +++ b/.changeset/silly-trainers-clean.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-app-api': patch +'@backstage/core-plugin-api': patch +--- + +Switch to using utilities from `@backstage/version-bridge'. diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 5223d2b27e..790b1e29fb 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -33,6 +33,7 @@ "@backstage/config": "^0.1.9", "@backstage/core-plugin-api": "^0.1.7", "@backstage/theme": "^0.2.10", + "@backstage/version-bridge": "^0.1.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@types/react": "*", diff --git a/packages/core-app-api/src/apis/system/ApiProvider.test.tsx b/packages/core-app-api/src/apis/system/ApiProvider.test.tsx index b94aea6b67..d1793f890e 100644 --- a/packages/core-app-api/src/apis/system/ApiProvider.test.tsx +++ b/packages/core-app-api/src/apis/system/ApiProvider.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { Context, useContext } from 'react'; +import React from 'react'; import { useApi, createApiRef, @@ -26,8 +26,7 @@ import { ApiProvider } from './ApiProvider'; import { ApiRegistry } from './ApiRegistry'; import { render } from '@testing-library/react'; import { withLogCollector } from '@backstage/test-utils-core'; -import { getGlobalSingleton } from '../../lib/globalObject'; -import { VersionedValue } from '../../lib/versionedValues'; +import { useVersionedContext } from '@backstage/version-bridge'; describe('ApiProvider', () => { type Api = () => string; @@ -116,11 +115,11 @@ describe('ApiProvider', () => { withLogCollector(['error'], () => { expect(() => { render(); - }).toThrow(/^No provider available for api-context context/); + }).toThrow(/^API context is not available/); }).error, ).toEqual([ expect.stringMatching( - /^Error: Uncaught \[Error: No provider available for api-context context/, + /^Error: Uncaught \[Error: API context is not available/, ), expect.stringMatching( /^The above error occurred in the component/, @@ -131,11 +130,11 @@ describe('ApiProvider', () => { withLogCollector(['error'], () => { expect(() => { render(); - }).toThrow(/^No provider available for api-context context/); + }).toThrow(/^API context is not available/); }).error, ).toEqual([ expect.stringMatching( - /^Error: Uncaught \[Error: No provider available for api-context context/, + /^Error: Uncaught \[Error: API context is not available/, ), expect.stringMatching( /^The above error occurred in the component/, @@ -185,13 +184,10 @@ describe('ApiProvider', () => { }); describe('v1 consumer', () => { - const ApiContext = - getGlobalSingleton>>( - 'api-context', - ); - function useMockApiV1(apiRef: ApiRef): T { - const impl = useContext(ApiContext)?.atVersion(1)?.get(apiRef); + const impl = useVersionedContext<{ 1: ApiHolder }>('api-context') + ?.atVersion(1) + ?.get(apiRef); if (!impl) { throw new Error('no impl'); } diff --git a/packages/core-app-api/src/apis/system/ApiProvider.tsx b/packages/core-app-api/src/apis/system/ApiProvider.tsx index ce6c388087..aa57fa9310 100644 --- a/packages/core-app-api/src/apis/system/ApiProvider.tsx +++ b/packages/core-app-api/src/apis/system/ApiProvider.tsx @@ -14,30 +14,21 @@ * limitations under the License. */ -import React, { - createContext, - useContext, - ReactNode, - PropsWithChildren, -} from 'react'; +import React, { useContext, ReactNode, PropsWithChildren } from 'react'; import PropTypes from 'prop-types'; import { ApiHolder } from '@backstage/core-plugin-api'; import { ApiAggregator } from './ApiAggregator'; -import { getOrCreateGlobalSingleton } from '../../lib/globalObject'; import { - VersionedValue, createVersionedValueMap, -} from '../../lib/versionedValues'; + createVersionedContext, +} from '@backstage/version-bridge'; type ApiProviderProps = { apis: ApiHolder; children: ReactNode; }; -type ApiContextType = VersionedValue<{ 1: ApiHolder }> | undefined; -const ApiContext = getOrCreateGlobalSingleton('api-context', () => - createContext(undefined), -); +const ApiContext = createVersionedContext<{ 1: ApiHolder }>('api-context'); export const ApiProvider = ({ apis, diff --git a/packages/core-app-api/src/app/AppContext.test.tsx b/packages/core-app-api/src/app/AppContext.test.tsx index 6ce164b9f0..a1131969f2 100644 --- a/packages/core-app-api/src/app/AppContext.test.tsx +++ b/packages/core-app-api/src/app/AppContext.test.tsx @@ -14,21 +14,16 @@ * limitations under the License. */ -import React, { useContext, Context } from 'react'; +import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; -import { VersionedValue } from '../lib/versionedValues'; -import { getGlobalSingleton } from '../lib/globalObject'; +import { useVersionedContext } from '@backstage/version-bridge'; import { AppContext as AppContextV1 } from './types'; import { AppContextProvider } from './AppContext'; describe('v1 consumer', () => { - const AppContext = - getGlobalSingleton>>( - 'app-context', - ); - function useMockAppV1(): AppContextV1 { - const impl = useContext(AppContext)?.atVersion(1); + const impl = + useVersionedContext<{ 1: AppContextV1 }>('app-context')?.atVersion(1); if (!impl) { throw new Error('no impl'); } diff --git a/packages/core-app-api/src/app/AppContext.tsx b/packages/core-app-api/src/app/AppContext.tsx index c583478ec2..f39d9a095e 100644 --- a/packages/core-app-api/src/app/AppContext.tsx +++ b/packages/core-app-api/src/app/AppContext.tsx @@ -14,18 +14,14 @@ * limitations under the License. */ -import React, { createContext, PropsWithChildren } from 'react'; +import React, { PropsWithChildren } from 'react'; import { - VersionedValue, createVersionedValueMap, -} from '../lib/versionedValues'; -import { getOrCreateGlobalSingleton } from '../lib/globalObject'; + createVersionedContext, +} from '@backstage/version-bridge'; import { AppContext as AppContextV1 } from './types'; -type AppContextType = VersionedValue<{ 1: AppContextV1 }> | undefined; -const AppContext = getOrCreateGlobalSingleton('app-context', () => - createContext(undefined), -); +const AppContext = createVersionedContext<{ 1: AppContextV1 }>('app-context'); type Props = { appContext: AppContextV1; diff --git a/packages/core-app-api/src/lib/globalObject.test.ts b/packages/core-app-api/src/lib/globalObject.test.ts deleted file mode 100644 index a658b253a6..0000000000 --- a/packages/core-app-api/src/lib/globalObject.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2021 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 { - getGlobalSingleton, - getOrCreateGlobalSingleton, - setGlobalSingleton, -} from './globalObject'; - -const anyGlobal = global as any; - -describe('getGlobalSingleton', () => { - beforeEach(() => { - delete anyGlobal['__@backstage/my-thing__']; - }); - - it('should return an existing value', () => { - const myThing = {}; - const myOtherThing = {}; - - anyGlobal['__@backstage/my-thing__'] = myThing; - expect(getGlobalSingleton('my-thing')).toBe(myThing); - expect(getGlobalSingleton('my-thing')).toBe(myThing); - anyGlobal['__@backstage/my-thing__'] = myOtherThing; - expect(getGlobalSingleton('my-thing')).toBe(myOtherThing); - }); - - it('should throw if the value is not set', () => { - expect(() => getGlobalSingleton('my-thing')).toThrow( - 'Global my-thing is not set', - ); - }); -}); - -describe('getOrCreateGlobalSingleton', () => { - beforeEach(() => { - delete anyGlobal['__@backstage/my-thing__']; - }); - - it('should return an existing value', () => { - const myThing = {}; - anyGlobal['__@backstage/my-thing__'] = myThing; - - expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); - expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); - }); - - it('should should create a new value', () => { - const myNewThing = {}; - - expect(anyGlobal['__@backstage/my-thing__']).toBe(undefined); - expect(getOrCreateGlobalSingleton('my-thing', () => myNewThing)).toBe( - myNewThing, - ); - expect(anyGlobal['__@backstage/my-thing__']).toBe(myNewThing); - expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myNewThing); - }); -}); - -describe('setGlobalSingleton', () => { - beforeEach(() => { - delete anyGlobal['__@backstage/my-thing__']; - }); - - it('should set a global value', () => { - setGlobalSingleton('my-thing', 'global value'); - - expect(anyGlobal['__@backstage/my-thing__']).toBe('global value'); - }); - - it('should throw if global value is set', () => { - anyGlobal['__@backstage/my-thing__'] = 'already defined'; - expect(() => setGlobalSingleton('my-thing', () => 'global value')).toThrow( - 'Global my-thing is already se', - ); - }); -}); diff --git a/packages/core-app-api/src/lib/globalObject.ts b/packages/core-app-api/src/lib/globalObject.ts deleted file mode 100644 index ad70a61110..0000000000 --- a/packages/core-app-api/src/lib/globalObject.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2021 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. - */ - -// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 -function getGlobalObject() { - if (typeof window !== 'undefined' && window.Math === Math) { - return window; - } - if (typeof self !== 'undefined' && self.Math === Math) { - return self; - } - // eslint-disable-next-line no-new-func - return Function('return this')(); -} - -const globalObject = getGlobalObject(); - -const makeKey = (id: string) => `__@backstage/${id}__`; - -/** - * Used to provide a global singleton value, failing if it is already set. - */ -export function setGlobalSingleton(id: string, value: unknown): void { - const key = makeKey(id); - if (key in globalObject) { - throw new Error(`Global ${id} is already set`); // TODO some sort of special build err - } - globalObject[key] = value; -} - -/** - * Used to access a global singleton value, failing if it is not already set. - */ -export function getGlobalSingleton(id: string): T { - const key = makeKey(id); - if (!(key in globalObject)) { - throw new Error(`Global ${id} is not set`); // TODO some sort of special build err - } - - return globalObject[key]; -} - -/** - * Serializes access to a global singleton value, with the first caller creating the value. - */ -export function getOrCreateGlobalSingleton( - id: string, - supplier: () => T, -): T { - const key = makeKey(id); - - let value = globalObject[key]; - if (value) { - return value; - } - - value = supplier(); - globalObject[key] = value; - return value; -} diff --git a/packages/core-app-api/src/routing/RoutingProvider.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.test.tsx index 47f9c87c96..397217e34a 100644 --- a/packages/core-app-api/src/routing/RoutingProvider.test.tsx +++ b/packages/core-app-api/src/routing/RoutingProvider.test.tsx @@ -14,17 +14,11 @@ * limitations under the License. */ -import React, { - PropsWithChildren, - ReactElement, - useContext, - Context, -} from 'react'; +import React, { PropsWithChildren, ReactElement } from 'react'; import { MemoryRouter, Routes } from 'react-router-dom'; import { render } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; -import { VersionedValue } from '../lib/versionedValues'; -import { getGlobalSingleton } from '../lib/globalObject'; +import { useVersionedContext } from '@backstage/version-bridge'; import { childDiscoverer, routeElementDiscoverer, @@ -331,16 +325,13 @@ describe('discovery', () => { }); describe('v1 consumer', () => { - const RoutingContext = - getGlobalSingleton>>( - 'routing-context', - ); - function useMockRouteRefV1( routeRef: AnyRouteRef, location: string, ): RouteFunc | undefined { - const resolver = useContext(RoutingContext)?.atVersion(1); + const resolver = useVersionedContext<{ + 1: RouteResolver; + }>('routing-context')?.atVersion(1); if (!resolver) { throw new Error('no impl'); } diff --git a/packages/core-app-api/src/routing/RoutingProvider.tsx b/packages/core-app-api/src/routing/RoutingProvider.tsx index 2192f5df26..2ab1686368 100644 --- a/packages/core-app-api/src/routing/RoutingProvider.tsx +++ b/packages/core-app-api/src/routing/RoutingProvider.tsx @@ -14,24 +14,21 @@ * limitations under the License. */ -import React, { createContext, ReactNode } from 'react'; +import React, { ReactNode } from 'react'; import { ExternalRouteRef, RouteRef, SubRouteRef, } from '@backstage/core-plugin-api'; -import { getOrCreateGlobalSingleton } from '../lib/globalObject'; import { createVersionedValueMap, - VersionedValue, -} from '../lib/versionedValues'; + createVersionedContext, +} from '@backstage/version-bridge'; import { RouteResolver } from './RouteResolver'; import { BackstageRouteObject } from './types'; -type RoutingContextType = VersionedValue<{ 1: RouteResolver }> | undefined; -const RoutingContext = getOrCreateGlobalSingleton('routing-context', () => - createContext(undefined), -); +const RoutingContext = + createVersionedContext<{ 1: RouteResolver }>('routing-context'); type ProviderProps = { routePaths: Map; diff --git a/packages/core-app-api/src/routing/types.ts b/packages/core-app-api/src/routing/types.ts index a12e914aba..12bf0d1a0f 100644 --- a/packages/core-app-api/src/routing/types.ts +++ b/packages/core-app-api/src/routing/types.ts @@ -19,7 +19,7 @@ import { SubRouteRef, ExternalRouteRef, } from '@backstage/core-plugin-api'; -import { getOrCreateGlobalSingleton } from '../lib/globalObject'; +import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; type RouteRefType = Exclude< keyof RouteRef, diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 414ca2afe1..d9380a3245 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -31,6 +31,7 @@ "dependencies": { "@backstage/config": "^0.1.9", "@backstage/theme": "^0.2.9", + "@backstage/version-bridge": "^0.1.0", "@material-ui/core": "^4.12.2", "@types/react": "*", "history": "^5.0.0", diff --git a/packages/core-plugin-api/src/apis/system/useApi.test.tsx b/packages/core-plugin-api/src/apis/system/useApi.test.tsx index b23e44b815..6f473d8f59 100644 --- a/packages/core-plugin-api/src/apis/system/useApi.test.tsx +++ b/packages/core-plugin-api/src/apis/system/useApi.test.tsx @@ -15,7 +15,7 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { createVersionedContextForTesting } from '../../lib/versionedValues'; +import { createVersionedContextForTesting } from '@backstage/version-bridge'; import { createApiRef } from './ApiRef'; import { useApi } from './useApi'; diff --git a/packages/core-plugin-api/src/apis/system/useApi.tsx b/packages/core-plugin-api/src/apis/system/useApi.tsx index eae6b98654..ef598a4c0e 100644 --- a/packages/core-plugin-api/src/apis/system/useApi.tsx +++ b/packages/core-plugin-api/src/apis/system/useApi.tsx @@ -16,10 +16,13 @@ import React, { PropsWithChildren } from 'react'; import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; -import { useVersionedContext } from '../../lib/versionedValues'; +import { useVersionedContext } from '@backstage/version-bridge'; export function useApiHolder(): ApiHolder { const versionedHolder = useVersionedContext<{ 1: ApiHolder }>('api-context'); + if (!versionedHolder) { + throw new Error('API context is not available'); + } const apiHolder = versionedHolder.atVersion(1); if (!apiHolder) { diff --git a/packages/core-plugin-api/src/app/useApp.test.tsx b/packages/core-plugin-api/src/app/useApp.test.tsx index 12b315ae87..f2e330e847 100644 --- a/packages/core-plugin-api/src/app/useApp.test.tsx +++ b/packages/core-plugin-api/src/app/useApp.test.tsx @@ -15,7 +15,7 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { createVersionedContextForTesting } from '../lib/versionedValues'; +import { createVersionedContextForTesting } from '@backstage/version-bridge'; import { useApp } from './useApp'; describe('v1 consumer', () => { diff --git a/packages/core-plugin-api/src/app/useApp.tsx b/packages/core-plugin-api/src/app/useApp.tsx index aa323db4e2..05d2e1c5c0 100644 --- a/packages/core-plugin-api/src/app/useApp.tsx +++ b/packages/core-plugin-api/src/app/useApp.tsx @@ -14,12 +14,16 @@ * limitations under the License. */ -import { useVersionedContext } from '../lib/versionedValues'; +import { useVersionedContext } from '@backstage/version-bridge'; import { AppContext as AppContextV1 } from './types'; export const useApp = (): AppContextV1 => { const versionedContext = useVersionedContext<{ 1: AppContextV1 }>('app-context'); + if (!versionedContext) { + throw new Error('App context is not available'); + } + const appContext = versionedContext.atVersion(1); if (!appContext) { throw new Error('AppContext v1 not available'); diff --git a/packages/core-plugin-api/src/extensions/componentData.tsx b/packages/core-plugin-api/src/extensions/componentData.tsx index d28d17d8ca..97a0b74e5b 100644 --- a/packages/core-plugin-api/src/extensions/componentData.tsx +++ b/packages/core-plugin-api/src/extensions/componentData.tsx @@ -15,7 +15,7 @@ */ import { ComponentType, ReactNode } from 'react'; -import { getOrCreateGlobalSingleton } from '../lib/globalObject'; +import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; type DataContainer = { map: Map; diff --git a/packages/core-plugin-api/src/lib/versionedValues.ts b/packages/core-plugin-api/src/lib/versionedValues.ts deleted file mode 100644 index 7686842ecd..0000000000 --- a/packages/core-plugin-api/src/lib/versionedValues.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2021 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 { createContext, useContext, Context } from 'react'; -import { getGlobalSingleton, setGlobalSingleton } from './globalObject'; - -/** - * The versioned value interface is a container for a set of values that - * can be looked up by version. It is intended to be used as a container - * for values that can be versioned independently of package versions. - */ -export type VersionedValue = { - atVersion( - version: Version, - ): Versions[Version] | undefined; -}; - -/** - * Creates a container for a map of versioned values that implements VersionedValue. - */ -export function createVersionedValueMap< - Versions extends { [version: number]: any }, ->(versions: Versions): VersionedValue { - Object.freeze(versions); - return { - atVersion(version) { - return versions[version]; - }, - }; -} - -export function useVersionedContext< - Versions extends { [version in number]: any }, ->(key: string): VersionedValue { - const versionedValue = useContext( - getGlobalSingleton>>(key), - ); - if (!versionedValue) { - throw new Error(`No provider available for ${key} context`); - } - return versionedValue; -} - -export function createVersionedContextForTesting(key: string) { - return { - set(versions: { [version in number]: unknown }) { - setGlobalSingleton(key, createContext(createVersionedValueMap(versions))); - }, - reset() { - delete (globalThis as any)[`__@backstage/${key}__`]; - }, - }; -} diff --git a/packages/core-plugin-api/src/routing/types.ts b/packages/core-plugin-api/src/routing/types.ts index 5afcc72265..86d469c2cc 100644 --- a/packages/core-plugin-api/src/routing/types.ts +++ b/packages/core-plugin-api/src/routing/types.ts @@ -15,7 +15,7 @@ */ import { OldIconComponent } from '../icons/types'; -import { getOrCreateGlobalSingleton } from '../lib/globalObject'; +import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; export type AnyParams = { [param in string]: string } | undefined; export type ParamKeys = keyof Params extends never diff --git a/packages/core-plugin-api/src/routing/useRouteRef.test.tsx b/packages/core-plugin-api/src/routing/useRouteRef.test.tsx index ace2d1a58a..62f6d6da42 100644 --- a/packages/core-plugin-api/src/routing/useRouteRef.test.tsx +++ b/packages/core-plugin-api/src/routing/useRouteRef.test.tsx @@ -17,7 +17,7 @@ import { renderHook } from '@testing-library/react-hooks'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { createVersionedContextForTesting } from '../lib/versionedValues'; +import { createVersionedContextForTesting } from '@backstage/version-bridge'; import { useRouteRef } from './useRouteRef'; import { createRouteRef } from './RouteRef'; diff --git a/packages/core-plugin-api/src/routing/useRouteRef.tsx b/packages/core-plugin-api/src/routing/useRouteRef.tsx index ed261489a4..ea05cc14fd 100644 --- a/packages/core-plugin-api/src/routing/useRouteRef.tsx +++ b/packages/core-plugin-api/src/routing/useRouteRef.tsx @@ -16,7 +16,7 @@ import { useMemo } from 'react'; import { matchRoutes, useLocation } from 'react-router-dom'; -import { useVersionedContext } from '../lib/versionedValues'; +import { useVersionedContext } from '@backstage/version-bridge'; import { AnyParams, ExternalRouteRef, @@ -50,6 +50,10 @@ export function useRouteRef( const sourceLocation = useLocation(); const versionedContext = useVersionedContext<{ 1: RouteResolver }>('routing-context'); + if (!versionedContext) { + throw new Error('Routing context is not available'); + } + const resolver = versionedContext.atVersion(1); const routeFunc = useMemo( () => resolver && resolver.resolve(routeRef, sourceLocation), diff --git a/packages/version-bridge/.eslintrc.js b/packages/version-bridge/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/version-bridge/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/version-bridge/README.md b/packages/version-bridge/README.md new file mode 100644 index 0000000000..e687b8c5a3 --- /dev/null +++ b/packages/version-bridge/README.md @@ -0,0 +1,10 @@ +# @backstage/version-bridge + +This package provides utilities to help enable support for multiple concurrent package versions within an app. + +It's currently only intended for use internally within @backstage packages. + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/version-bridge/api-report.md b/packages/version-bridge/api-report.md new file mode 100644 index 0000000000..eca13071ed --- /dev/null +++ b/packages/version-bridge/api-report.md @@ -0,0 +1,50 @@ +## API Report File for "@backstage/version-bridge" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Context } from 'react'; + +// @public +export function createVersionedContext< + Versions extends { + [version in number]: unknown; + }, +>(key: string): Context | undefined>; + +// @public +export function createVersionedContextForTesting(key: string): { + set(versions: { [x: number]: unknown }): void; + reset(): void; +}; + +// @public +export function createVersionedValueMap< + Versions extends { + [version: number]: unknown; + }, +>(versions: Versions): VersionedValue; + +// @public +export function getOrCreateGlobalSingleton(id: string, supplier: () => T): T; + +// @public +export function useVersionedContext< + Versions extends { + [version in number]: unknown; + }, +>(key: string): VersionedValue | undefined; + +// @public +export type VersionedValue< + Versions extends { + [version: number]: unknown; + }, +> = { + atVersion( + version: Version, + ): Versions[Version] | undefined; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/version-bridge/package.json b/packages/version-bridge/package.json new file mode 100644 index 0000000000..3adbac681d --- /dev/null +++ b/packages/version-bridge/package.json @@ -0,0 +1,44 @@ +{ + "name": "@backstage/version-bridge", + "description": "Utilities used by @backstage packages to support multiple concurrent versions", + "version": "0.1.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/version-bridge" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "backstage-cli build --outputs types,esm", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@types/react": "*", + "react": "^16.12.0" + }, + "devDependencies": { + "@backstage/cli": "^0.7.11", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^3.4.2" + }, + "files": [ + "dist" + ] +} diff --git a/packages/version-bridge/src/index.ts b/packages/version-bridge/src/index.ts new file mode 100644 index 0000000000..3e6b2a00f4 --- /dev/null +++ b/packages/version-bridge/src/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 './lib'; diff --git a/packages/version-bridge/src/lib/VersionedContext.test.tsx b/packages/version-bridge/src/lib/VersionedContext.test.tsx new file mode 100644 index 0000000000..659fd9b5b9 --- /dev/null +++ b/packages/version-bridge/src/lib/VersionedContext.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright 2021 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 } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { + createVersionedContext, + createVersionedContextForTesting, + useVersionedContext, +} from './VersionedContext'; +import { createVersionedValueMap } from './VersionedValue'; + +type ContextType = { 1: string; 2: string }; + +describe('VersionedContext', () => { + it('should provide a versioned value', () => { + const Context = createVersionedContext('test-context-1'); + + const rendered = renderHook(() => useContext(Context), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(rendered.result.current?.atVersion(1)).toBe('1v1'); + expect(rendered.result.current?.atVersion(2)).toBe('1v2'); + }); + + it('should provide a versioned value to hook', () => { + const Context = createVersionedContext('test-context-2'); + + const rendered = renderHook(() => useVersionedContext('test-context-2'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(rendered.result.current?.atVersion(1)).toBe('2v1'); + expect(rendered.result.current?.atVersion(2)).toBe('2v2'); + }); + + it('should be provide a test utility', () => { + const context = createVersionedContextForTesting('test-context-3'); + + const rendered = renderHook(() => useVersionedContext('test-context-3')); + + expect(rendered.result.current).toBeUndefined(); + context.set({ 1: '3v1' }); + expect(rendered.result.current).toBeUndefined(); + // should need a rerender before update + rendered.rerender(); + + expect(rendered.result.current?.atVersion(1)).toBe('3v1'); + expect(rendered.result.current?.atVersion(2)).toBeUndefined(); + + context.set({ 2: '3v2' }); + rendered.rerender(); + + expect(rendered.result.current?.atVersion(1)).toBeUndefined(); + expect(rendered.result.current?.atVersion(2)).toBe('3v2'); + + context.reset(); + rendered.rerender(); + expect(rendered.result.current).toBeUndefined(); + + context.set({ 1: '3v1', 2: '3v2' }); + + rendered.rerender(); + + expect(rendered.result.current?.atVersion(1)).toBe('3v1'); + expect(rendered.result.current?.atVersion(2)).toBe('3v2'); + }); +}); diff --git a/packages/version-bridge/src/lib/VersionedContext.ts b/packages/version-bridge/src/lib/VersionedContext.ts new file mode 100644 index 0000000000..ea0c40bbc4 --- /dev/null +++ b/packages/version-bridge/src/lib/VersionedContext.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2021 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 { createContext, useContext, Context } from 'react'; +import { getOrCreateGlobalSingleton } from './globalObject'; +import { createVersionedValueMap, VersionedValue } from './VersionedValue'; + +/** + * Get the existing or create a new versioned React context that's + * stored inside a global singleton. + * + * @param key - A key that uniquely identifies the context. + * @public + * @example + * + * ```ts + * const MyContext = createVersionedContext<{ 1: string }>('my-context'); + * + * const MyContextProvider = ({children}) => ( + * + * {children} + * + * ) + * ``` + */ +export function createVersionedContext< + Versions extends { [version in number]: unknown }, +>(key: string): Context | undefined> { + return getOrCreateGlobalSingleton(key, () => + createContext | undefined>(undefined), + ); +} + +/** + * A hook that simplifies the consumption of a versioned contexts that's + * stored inside a global singleton. + * + * @param key - A key that uniquely identifies the context. + * @public + * @example + * + * ```ts + * const versionedHolder = useVersionedContext<{ 1: string }>('my-context'); + * + * if (!versionedHolder) { + * throw new Error('My context is not available!') + * } + * + * const myValue = versionedHolder.atVersion(1); + * + * // ... + * ``` + */ +export function useVersionedContext< + Versions extends { [version in number]: unknown }, +>(key: string): VersionedValue | undefined { + return useContext(createVersionedContext(key)); +} + +/** + * Creates a helper for writing tests towards multiple different + * combinations of versions provided from a context. + * + * @param key - A key that uniquely identifies the context. + * @public + * @example + * + * ```ts + * const context = createVersionedContextForTesting('my-context'); + * + * afterEach(() => { + * context.reset(); + * }); + * + * it('should work when provided with version 1', () => { + * context.set({1: 'value-for-version-1'}) + * + * // ... + * }) + * ``` + */ +export function createVersionedContextForTesting(key: string) { + return { + set(versions: { [version in number]: unknown }) { + (globalThis as any)[`__@backstage/${key}__`] = createContext( + createVersionedValueMap(versions), + ); + }, + reset() { + delete (globalThis as any)[`__@backstage/${key}__`]; + }, + }; +} diff --git a/packages/core-app-api/src/lib/versionedValues.test.ts b/packages/version-bridge/src/lib/VersionedValue.test.ts similarity index 95% rename from packages/core-app-api/src/lib/versionedValues.test.ts rename to packages/version-bridge/src/lib/VersionedValue.test.ts index 19f9c8f349..2b6637a96f 100644 --- a/packages/core-app-api/src/lib/versionedValues.test.ts +++ b/packages/version-bridge/src/lib/VersionedValue.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createVersionedValueMap } from './versionedValues'; +import { createVersionedValueMap } from './VersionedValue'; describe('createVersionedValueMap', () => { it('should be empty', () => { diff --git a/packages/core-app-api/src/lib/versionedValues.ts b/packages/version-bridge/src/lib/VersionedValue.ts similarity index 93% rename from packages/core-app-api/src/lib/versionedValues.ts rename to packages/version-bridge/src/lib/VersionedValue.ts index 3b3064fc7e..b7c2153e38 100644 --- a/packages/core-app-api/src/lib/versionedValues.ts +++ b/packages/version-bridge/src/lib/VersionedValue.ts @@ -18,8 +18,10 @@ * The versioned value interface is a container for a set of values that * can be looked up by version. It is intended to be used as a container * for values that can be versioned independently of package versions. + * + * @public */ -export type VersionedValue = { +export type VersionedValue = { atVersion( version: Version, ): Versions[Version] | undefined; @@ -27,9 +29,11 @@ export type VersionedValue = { /** * Creates a container for a map of versioned values that implements VersionedValue. + * + * @public */ export function createVersionedValueMap< - Versions extends { [version: number]: any }, + Versions extends { [version: number]: unknown }, >(versions: Versions): VersionedValue { Object.freeze(versions); return { diff --git a/packages/core-plugin-api/src/lib/globalObject.test.ts b/packages/version-bridge/src/lib/globalObject.test.ts similarity index 65% rename from packages/core-plugin-api/src/lib/globalObject.test.ts rename to packages/version-bridge/src/lib/globalObject.test.ts index 13634f1f08..9c12ae1dfa 100644 --- a/packages/core-plugin-api/src/lib/globalObject.test.ts +++ b/packages/version-bridge/src/lib/globalObject.test.ts @@ -14,33 +14,10 @@ * limitations under the License. */ -import { getGlobalSingleton, getOrCreateGlobalSingleton } from './globalObject'; +import { getOrCreateGlobalSingleton } from './globalObject'; const anyGlobal = global as any; -describe('getGlobalSingleton', () => { - beforeEach(() => { - delete anyGlobal['__@backstage/my-thing__']; - }); - - it('should return an existing value', () => { - const myThing = {}; - const myOtherThing = {}; - - anyGlobal['__@backstage/my-thing__'] = myThing; - expect(getGlobalSingleton('my-thing')).toBe(myThing); - expect(getGlobalSingleton('my-thing')).toBe(myThing); - anyGlobal['__@backstage/my-thing__'] = myOtherThing; - expect(getGlobalSingleton('my-thing')).toBe(myOtherThing); - }); - - it('should throw if the value is not set', () => { - expect(() => getGlobalSingleton('my-thing')).toThrow( - 'Global my-thing is not set', - ); - }); -}); - describe('getOrCreateGlobalSingleton', () => { beforeEach(() => { delete anyGlobal['__@backstage/my-thing__']; diff --git a/packages/core-plugin-api/src/lib/globalObject.ts b/packages/version-bridge/src/lib/globalObject.ts similarity index 70% rename from packages/core-plugin-api/src/lib/globalObject.ts rename to packages/version-bridge/src/lib/globalObject.ts index 7a400148fb..b29e81e4a0 100644 --- a/packages/core-plugin-api/src/lib/globalObject.ts +++ b/packages/version-bridge/src/lib/globalObject.ts @@ -30,31 +30,10 @@ const globalObject = getGlobalObject(); const makeKey = (id: string) => `__@backstage/${id}__`; -/** - * Used to provide a global singleton value, failing if it is already set. - */ -export function setGlobalSingleton(id: string, value: unknown): void { - const key = makeKey(id); - if (key in globalObject) { - throw new Error(`Global ${id} is already set`); - } - globalObject[key] = value; -} - -/** - * Used to access a global singleton value, failing if it is not already set. - */ -export function getGlobalSingleton(id: string): T { - const key = makeKey(id); - if (!(key in globalObject)) { - throw new Error(`Global ${id} is not set`); - } - - return globalObject[key]; -} - /** * Serializes access to a global singleton value, with the first caller creating the value. + * + * @public */ export function getOrCreateGlobalSingleton( id: string, diff --git a/packages/version-bridge/src/lib/index.ts b/packages/version-bridge/src/lib/index.ts new file mode 100644 index 0000000000..191abe7444 --- /dev/null +++ b/packages/version-bridge/src/lib/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { getOrCreateGlobalSingleton } from './globalObject'; +export { + createVersionedContextForTesting, + useVersionedContext, + createVersionedContext, +} from './VersionedContext'; +export { createVersionedValueMap } from './VersionedValue'; +export type { VersionedValue } from './VersionedValue'; diff --git a/packages/version-bridge/src/setupTests.ts b/packages/version-bridge/src/setupTests.ts new file mode 100644 index 0000000000..963c0f188b --- /dev/null +++ b/packages/version-bridge/src/setupTests.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. + */ + +import '@testing-library/jest-dom';