Merge pull request #7143 from backstage/rugvip/bridge

Extract version bridging logic from core into new version-bridge package
This commit is contained in:
Patrik Oldsberg
2021-09-14 10:32:16 +02:00
committed by GitHub
34 changed files with 434 additions and 353 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-app-api': patch
'@backstage/core-plugin-api': patch
---
Switch to using utilities from `@backstage/version-bridge'.
+1
View File
@@ -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": "*",
@@ -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(<MyHookConsumer />);
}).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 <MyHookConsumer> component/,
@@ -131,11 +130,11 @@ describe('ApiProvider', () => {
withLogCollector(['error'], () => {
expect(() => {
render(<MyHocConsumer />);
}).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 <withApis\(Component\)> component/,
@@ -185,13 +184,10 @@ describe('ApiProvider', () => {
});
describe('v1 consumer', () => {
const ApiContext =
getGlobalSingleton<Context<VersionedValue<{ 1: ApiHolder }>>>(
'api-context',
);
function useMockApiV1<T>(apiRef: ApiRef<T>): 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');
}
@@ -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<ApiContextType>(undefined),
);
const ApiContext = createVersionedContext<{ 1: ApiHolder }>('api-context');
export const ApiProvider = ({
apis,
@@ -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<Context<VersionedValue<{ 1: AppContextV1 }>>>(
'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');
}
+4 -8
View File
@@ -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<AppContextType | undefined>(undefined),
);
const AppContext = createVersionedContext<{ 1: AppContextV1 }>('app-context');
type Props = {
appContext: AppContextV1;
@@ -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',
);
});
});
@@ -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<T>(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<T>(
id: string,
supplier: () => T,
): T {
const key = makeKey(id);
let value = globalObject[key];
if (value) {
return value;
}
value = supplier();
globalObject[key] = value;
return value;
}
@@ -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<Context<VersionedValue<{ 1: RouteResolver }>>>(
'routing-context',
);
function useMockRouteRefV1(
routeRef: AnyRouteRef,
location: string,
): RouteFunc<any> | undefined {
const resolver = useContext(RoutingContext)?.atVersion(1);
const resolver = useVersionedContext<{
1: RouteResolver;
}>('routing-context')?.atVersion(1);
if (!resolver) {
throw new Error('no impl');
}
@@ -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<RoutingContextType>(undefined),
);
const RoutingContext =
createVersionedContext<{ 1: RouteResolver }>('routing-context');
type ProviderProps = {
routePaths: Map<RouteRef, string>;
+1 -1
View File
@@ -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,
+1
View File
@@ -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",
@@ -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';
@@ -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) {
@@ -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', () => {
+5 -1
View File
@@ -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');
@@ -15,7 +15,7 @@
*/
import { ComponentType, ReactNode } from 'react';
import { getOrCreateGlobalSingleton } from '../lib/globalObject';
import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
type DataContainer = {
map: Map<string, unknown>;
@@ -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<Versions extends { [version: number]: any }> = {
atVersion<Version extends keyof Versions>(
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<Versions> {
Object.freeze(versions);
return {
atVersion(version) {
return versions[version];
},
};
}
export function useVersionedContext<
Versions extends { [version in number]: any },
>(key: string): VersionedValue<Versions> {
const versionedValue = useContext(
getGlobalSingleton<Context<VersionedValue<Versions>>>(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}__`];
},
};
}
@@ -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<Params extends AnyParams> = keyof Params extends never
@@ -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';
@@ -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<Params extends AnyParams>(
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),
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+10
View File
@@ -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)
+50
View File
@@ -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<VersionedValue<Versions> | 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<Versions>;
// @public
export function getOrCreateGlobalSingleton<T>(id: string, supplier: () => T): T;
// @public
export function useVersionedContext<
Versions extends {
[version in number]: unknown;
},
>(key: string): VersionedValue<Versions> | undefined;
// @public
export type VersionedValue<
Versions extends {
[version: number]: unknown;
},
> = {
atVersion<Version extends keyof Versions>(
version: Version,
): Versions[Version] | undefined;
};
// (No @packageDocumentation comment for this package)
```
+44
View File
@@ -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"
]
}
+17
View File
@@ -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';
@@ -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<ContextType>('test-context-1');
const rendered = renderHook(() => useContext(Context), {
wrapper: ({ children }) => (
<Context.Provider
value={createVersionedValueMap({ 1: '1v1', 2: '1v2' })}
>
{children}
</Context.Provider>
),
});
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<ContextType>('test-context-2');
const rendered = renderHook(() => useVersionedContext('test-context-2'), {
wrapper: ({ children }) => (
<Context.Provider
value={createVersionedValueMap({ 1: '2v1', 2: '2v2' })}
>
{children}
</Context.Provider>
),
});
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');
});
});
@@ -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}) => (
* <MyContext.Provider value={createVersionedValueMap({ 1: 'value-for-version-1' })}>
* {children}
* <MyContext.Provider>
* )
* ```
*/
export function createVersionedContext<
Versions extends { [version in number]: unknown },
>(key: string): Context<VersionedValue<Versions> | undefined> {
return getOrCreateGlobalSingleton(key, () =>
createContext<VersionedValue<Versions> | 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<Versions> | undefined {
return useContext(createVersionedContext<Versions>(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}__`];
},
};
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { createVersionedValueMap } from './versionedValues';
import { createVersionedValueMap } from './VersionedValue';
describe('createVersionedValueMap', () => {
it('should be empty', () => {
@@ -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<Versions extends { [version: number]: any }> = {
export type VersionedValue<Versions extends { [version: number]: unknown }> = {
atVersion<Version extends keyof Versions>(
version: Version,
): Versions[Version] | undefined;
@@ -27,9 +29,11 @@ export type VersionedValue<Versions extends { [version: number]: any }> = {
/**
* 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<Versions> {
Object.freeze(versions);
return {
@@ -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__'];
@@ -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<T>(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<T>(
id: string,
+24
View File
@@ -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';
+17
View File
@@ -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';