Skip to content

Commit

Permalink
Support code references at any level within extension's properties
Browse files Browse the repository at this point in the history
  • Loading branch information
vojtechszocs committed Jul 27, 2021
1 parent 5a73789 commit 42a671e
Show file tree
Hide file tree
Showing 18 changed files with 242 additions and 132 deletions.
1 change: 1 addition & 0 deletions frontend/@types/console/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ declare module '*.svg' {
const value: any;
export = value;
}

declare module '*.png' {
const value: any;
export = value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useExtensions } from '@console/plugin-sdk/src/api/useExtensions';
import { Extension, ExtensionTypeGuard } from '@console/plugin-sdk/src/typings/base';
import { resolveExtension } from '../coderefs/coderef-resolver';
import { ResolvedExtension } from '../types';
import { unwrapPromiseSettledResults } from '../utils/promise';
import { settleAllPromises } from '../utils/promise';

/**
* React hook for consuming Console extensions with resolved `CodeRef` properties.
Expand Down Expand Up @@ -47,13 +47,11 @@ export const useResolvedExtensions = <E extends Extension>(
React.useEffect(() => {
let disposed = false;

// The promise returned by Promise.allSettled() never rejects; no need for catch-or-return.
// eslint-disable-next-line promise/catch-or-return
Promise.allSettled(
settleAllPromises(
extensions.map((e) => resolveExtension<typeof e, any, ResolvedExtension<E>>(e)),
).then((results) => {
).then(([fulfilledValues, rejectedReasons]) => {
if (!disposed) {
const [fulfilledValues, rejectedReasons] = unwrapPromiseSettledResults(results);
setResolvedExtensions(fulfilledValues);
setErrors(rejectedReasons);
setResolved(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,9 @@ import {
applyCodeRefSymbol,
isEncodedCodeRef,
isExecutableCodeRef,
filterEncodedCodeRefProperties,
filterExecutableCodeRefProperties,
parseEncodedCodeRefValue,
loadReferencedObject,
resolveEncodedCodeRefs,
resolveCodeRefProperties,
resolveExtension,
} from '../coderef-resolver';

Expand Down Expand Up @@ -52,38 +49,6 @@ describe('isExecutableCodeRef', () => {
});
});

describe('filterEncodedCodeRefProperties', () => {
it('picks properties whose values match isEncodedCodeRef predicate', () => {
expect(
filterEncodedCodeRefProperties({
foo: { $codeRef: 'foo' },
bar: ['test'],
baz: () => {},
qux: getExecutableCodeRefMock('qux'),
}),
).toEqual({
foo: { $codeRef: 'foo' },
});
});
});

describe('filterExecutableCodeRefProperties', () => {
it('picks properties whose values match isExecutableCodeRef predicate', () => {
const ref = getExecutableCodeRefMock('qux');

expect(
filterExecutableCodeRefProperties({
foo: { $codeRef: 'foo' },
bar: ['test'],
baz: () => {},
qux: ref,
}),
).toEqual({
qux: ref,
});
});
});

describe('parseEncodedCodeRefValue', () => {
it('returns [moduleName, exportName] tuple if value has the right format', () => {
expect(parseEncodedCodeRefValue('foo.bar')).toEqual(['foo', 'bar']);
Expand Down Expand Up @@ -208,20 +173,26 @@ describe('loadReferencedObject', () => {
});

describe('resolveEncodedCodeRefs', () => {
it('replaces encoded code references with CodeRef functions', () => {
it('replaces encoded code references with CodeRef functions', async () => {
const extensions: Extension[] = [
{
type: 'Foo',
properties: { test: true },
properties: {
test: true,
qux: { $codeRef: 'mod.a' },
},
},
{
type: 'Bar',
properties: { baz: 1, qux: { $codeRef: 'a.b' } },
properties: {
test: [1],
baz: { test: { $codeRef: 'mod.b' } },
},
},
];

const errorCallback = jest.fn();
const [, entryModule] = getEntryModuleMocks({ b: 'value' });
const [, entryModule] = getEntryModuleMocks({ a: 'value1', b: 'value2' });

const resolvedExtensions = resolveEncodedCodeRefs(
extensions,
Expand All @@ -231,62 +202,93 @@ describe('resolveEncodedCodeRefs', () => {
);

expect(resolvedExtensions.length).toBe(extensions.length);
expect(resolvedExtensions[0]).toEqual(extensions[0]);

expect(_.omit(resolvedExtensions[1], 'properties.qux')).toEqual(
_.omit(extensions[1], 'properties.qux'),
);
expect(resolvedExtensions[0].properties.test).toBe(true);
expect(isExecutableCodeRef(resolvedExtensions[0].properties.qux)).toBe(true);
expect(await resolvedExtensions[0].properties.qux()).toBe('value1');

expect(isExecutableCodeRef(resolvedExtensions[1].properties.qux)).toBe(true);
expect(resolvedExtensions[1].properties.test).toEqual([1]);
expect(isExecutableCodeRef(resolvedExtensions[1].properties.baz.test)).toBe(true);
expect(await resolvedExtensions[1].properties.baz.test()).toBe('value2');
});
});

describe('resolveCodeRefProperties', () => {
it('replaces CodeRef functions with referenced objects', async () => {
it('clones the provided extensions array and its elements', () => {
const extensions: Extension[] = [
{
type: 'Foo',
properties: { test: true },
},
{
type: 'Bar',
properties: { baz: 1, qux: getExecutableCodeRefMock('value') },
},
{ type: 'Foo', properties: { test: true } },
{ type: 'Bar', properties: { test: [1] } },
];

expect(await resolveCodeRefProperties(extensions[0])).toEqual({ test: true });
expect(await resolveCodeRefProperties(extensions[1])).toEqual({ baz: 1, qux: 'value' });
const errorCallback = jest.fn();
const [, entryModule] = getEntryModuleMocks({});

const resolvedExtensions = resolveEncodedCodeRefs(
extensions,
entryModule,
'Test@1.2.3',
errorCallback,
);

expect(resolvedExtensions).not.toBe(extensions);
expect(resolvedExtensions).toEqual(extensions);

resolvedExtensions.forEach((e, index) => {
expect(e).not.toBe(extensions[index]);
expect(e).toEqual(extensions[index]);
});
});
});

describe('resolveExtension', () => {
it('returns an extension with CodeRef functions replaced with referenced objects', async () => {
it('replaces CodeRef functions with referenced objects', async () => {
const extensions: Extension[] = [
{
type: 'Foo',
properties: { test: true },
properties: {
test: true,
qux: getExecutableCodeRefMock('value1'),
},
},
{
type: 'Bar',
properties: { baz: 1, qux: getExecutableCodeRefMock('value') },
properties: {
test: [1],
baz: { test: getExecutableCodeRefMock('value2') },
},
},
];

expect(await resolveExtension(extensions[0])).toEqual({
type: 'Foo',
properties: { test: true },
properties: {
test: true,
qux: 'value1',
},
});

expect(await resolveExtension(extensions[1])).toEqual({
type: 'Bar',
properties: { baz: 1, qux: 'value' },
properties: {
test: [1],
baz: { test: 'value2' },
},
});
});

it('returns a new extension instance', async () => {
const testExtension: Extension = { type: 'Foo/Bar', properties: {} };
const resolvedExtension = await resolveExtension(testExtension);
it('returns the same extension instance if it has no CodeRef functions', async () => {
const e: Extension = {
type: 'Foo',
properties: { test: true },
};

expect(await resolveExtension(e)).toBe(e);
});

it('returns a new extension instance if it has CodeRef functions', async () => {
const e: Extension = {
type: 'Foo',
properties: { test: true, qux: getExecutableCodeRefMock('value1') },
};

expect(resolvedExtension).not.toBe(testExtension);
expect(Object.isFrozen(resolvedExtension)).toBe(true);
expect(await resolveExtension(e)).not.toBe(e);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import {
ExtensionProperties,
UpdateExtensionProperties,
} from '../types';
import { deepForOwn } from '../utils/object';
import { settleAllPromises } from '../utils/promise';
import { mergeExtensionProperties } from '../utils/store';

// TODO(vojtech): support code refs at any level within the properties object

const codeRefSymbol = Symbol('CodeRef');

export const applyCodeRefSymbol = <T = any>(ref: CodeRef<T>) => {
Expand All @@ -31,12 +31,6 @@ export const isExecutableCodeRef = (obj): obj is CodeRef =>
_.isEqual(Object.getOwnPropertySymbols(obj), [codeRefSymbol]) &&
obj[codeRefSymbol] === true;

export const filterEncodedCodeRefProperties = (properties) =>
_.pickBy(properties, isEncodedCodeRef) as { [propName: string]: EncodedCodeRef };

export const filterExecutableCodeRefProperties = (properties) =>
_.pickBy(properties, isExecutableCodeRef) as { [propName: string]: CodeRef };

/**
* Parse the `EncodedCodeRef` value into `[moduleName, exportName]` tuple.
*
Expand All @@ -61,7 +55,7 @@ export const loadReferencedObject = async <TExport = any>(
errorCallback: VoidFunction,
): Promise<TExport> => {
const [moduleName, exportName] = parseEncodedCodeRefValue(ref.$codeRef);
let requestedModule: object;
let requestedModule: {};

if (!moduleName) {
console.error(`Malformed code reference '${ref.$codeRef}' of plugin ${pluginID}`);
Expand Down Expand Up @@ -99,36 +93,15 @@ export const resolveEncodedCodeRefs = (
errorCallback: VoidFunction,
): Extension[] =>
_.cloneDeep(extensions).map((e) => {
const refs = filterEncodedCodeRefProperties(e.properties);

Object.entries(refs).forEach(([propName, ref]) => {
const executableCodeRef: CodeRef = async () =>
loadReferencedObject(ref, entryModule, pluginID, errorCallback);

e.properties[propName] = applyCodeRefSymbol(executableCodeRef);
deepForOwn<EncodedCodeRef>(e.properties, isEncodedCodeRef, (ref, key, obj) => {
obj[key] = applyCodeRefSymbol(async () =>
loadReferencedObject(ref, entryModule, pluginID, errorCallback),
);
});

return e;
});

/**
* Returns the properties of extension `E` with `CodeRef` functions replaced with referenced objects.
*/
export const resolveCodeRefProperties = async <E extends Extension<P>, P = ExtensionProperties<E>>(
extension: E,
): Promise<ResolvedCodeRefProperties<P>> => {
const refs = filterExecutableCodeRefProperties(extension.properties);
const resolvedValues = Object.assign({}, extension.properties);

await Promise.all(
Object.entries(refs).map(async ([propName, ref]) => {
resolvedValues[propName] = await ref();
}),
);

return resolvedValues as ResolvedCodeRefProperties<P>;
};

/**
* Returns an extension with its `CodeRef` properties replaced with referenced objects.
*/
Expand All @@ -139,6 +112,22 @@ export const resolveExtension = async <
>(
extension: E,
): Promise<R> => {
const resolvedProperties = await resolveCodeRefProperties<E, P>(extension);
const valueResolutions: Promise<void>[] = [];
const resolvedProperties = { ...extension.properties };

deepForOwn<CodeRef>(resolvedProperties, isExecutableCodeRef, (ref, key, obj) => {
valueResolutions.push(
ref().then((resolvedValue) => {
obj[key] = resolvedValue;
}),
);
});

if (valueResolutions.length === 0) {
return (extension as unknown) as R;
}

await settleAllPromises(valueResolutions);

return (mergeExtensionProperties(extension, resolvedProperties) as unknown) as R;
};
9 changes: 7 additions & 2 deletions frontend/packages/console-dynamic-plugin-sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,15 @@ export type EncodedCodeRef = { $codeRef: string };
export type CodeRef<T = any> = () => Promise<T>;

/**
* Infer resolved `CodeRef` properties from object `O`.
* Extract type `T` from `CodeRef<T>`.
*/
export type ExtractCodeRefType<R> = R extends CodeRef<infer T> ? T : never;

/**
* Infer resolved `CodeRef` properties from object `O` recursively.
*/
export type ResolvedCodeRefProperties<O extends {}> = {
[K in keyof O]: O[K] extends CodeRef<infer T> ? T : O[K];
[K in keyof O]: O[K] extends CodeRef ? ExtractCodeRefType<O[K]> : ResolvedCodeRefProperties<O[K]>;
};

/**
Expand Down

0 comments on commit 42a671e

Please sign in to comment.