diff --git a/src/API.ts b/src/API.ts index 7770493b..2da9ab69 100644 --- a/src/API.ts +++ b/src/API.ts @@ -426,6 +426,12 @@ export interface Shell extends Pick = { export const stateSlotKey: SlotKey = { name: 'state' } +export const subLayersSlotKey: SlotKey = { + name: 'sub-layers' +} const toShellToggleSet = (names: string[], isInstalled: boolean): ShellToggleSet => { return names.reduce((result: ShellToggleSet, name: string) => { @@ -183,8 +186,17 @@ export function createAppHost(initialEntryPointsOrPackages: EntryPointOrPackage[ declareSlot(mainViewSlotKey) declareSlot(stateSlotKey) + declareSlot(subLayersSlotKey) + addShells([appHostServicesEntryPoint]) + const getAllLayers = () => + layers.concat( + getSlot(subLayersSlotKey) + .getItems() + .map(({ contribution }) => contribution) + ) + const memoize = ( func: T, resolver: FunctionWithSameArgs @@ -259,7 +271,7 @@ miss: ${memoizedWithMissHit.miss} } function getLayerByName(layerName: string): InternalAPILayer { - const layer = _(layers).flatten().find({ name: layerName }) + const layer = _(getAllLayers()).flatten().find({ name: layerName }) if (!layer) { throw new Error(`Cannot find layer ${layerName}`) } @@ -268,7 +280,7 @@ miss: ${memoizedWithMissHit.miss} type Dependency = { layer?: InternalAPILayer; apiKey: SlotKey } | undefined function validateEntryPointLayer(entryPoint: EntryPoint) { - if (!entryPoint.getDependencyAPIs || !entryPoint.layer || _.isEmpty(layers)) { + if (!entryPoint.getDependencyAPIs || !entryPoint.layer || _.isEmpty(getAllLayers())) { return } const highestLevelDependencies: Dependency[] = _.chain(entryPoint.getDependencyAPIs()) @@ -954,6 +966,18 @@ miss: ${memoizedWithMissHit.miss} boundaryAspects.push(component) }, + contributeSubLayersDimension(subLayers) { + const dimension = layers.length + verifyLayersUniqueness((layers as APILayer[][]).concat(subLayers)) + getSlot(subLayersSlotKey).contribute( + shell, + subLayers.map(l => ({ + ...l, + dimension + })) + ) + }, + memoizeForState(func, resolver, shouldClear?) { const memoized = memoize(func, resolver) memoizedFunctions.push(shouldClear ? { f: memoized, shouldClear } : { f: memoized }) diff --git a/test/appHost.spec.ts b/test/appHost.spec.ts index 389b788d..4f0bcae5 100644 --- a/test/appHost.spec.ts +++ b/test/appHost.spec.ts @@ -1,6 +1,6 @@ import _ from 'lodash' -import { createAppHost, mainViewSlotKey, makeLazyEntryPoint, stateSlotKey } from '../src/appHost' +import { createAppHost, mainViewSlotKey, makeLazyEntryPoint, stateSlotKey, subLayersSlotKey } from '../src/appHost' import { AnySlotKey, AppHost, EntryPoint, Shell, SlotKey, AppHostOptions, HostLogger, PrivateShell } from '../src/API' import { @@ -428,7 +428,7 @@ describe('App Host', () => { const host = createAppHost([mockPackage], testHostOptions) const actual = sortSlotKeys(host.getAllSlotKeys()) - const expected = sortSlotKeys([AppHostAPI, mainViewSlotKey, stateSlotKey, MockAPI]) + const expected = sortSlotKeys([AppHostAPI, mainViewSlotKey, stateSlotKey, subLayersSlotKey, MockAPI]) expect(actual).toEqual(expected) }) @@ -1139,6 +1139,93 @@ describe('App Host', () => { }) ).not.toThrow() }) + + it('should enforce contributed layers dimension', async () => { + const MockAPI1: SlotKey<{}> = { name: 'Mock-Host-API', layer: 'HOST_0' } + const MockAPI2: SlotKey<{}> = { name: 'Mock-Shell-API', layer: ['HOST_1', 'SHELL_1'] } + const hostLayersDimension = [ + { + level: 0, + name: 'HOST_0' + }, + { + level: 1, + name: 'HOST_1' + } + ] + const shellLayersDimension = [ + { + level: 0, + name: 'SHELL_0' + }, + { + level: 1, + name: 'SHELL_1' + } + ] + + const EntryPoint1: EntryPoint = { + name: 'MOCK_ENTRY_POINT_1', + layer: 'HOST_0', + declareAPIs: () => [MockAPI1], + attach: shell => { + shell.contributeAPI(MockAPI1, () => ({})) + shell.contributeSubLayersDimension(shellLayersDimension) + } + } + + const EntryPoint2: EntryPoint = { + name: 'MOCK_ENTRY_POINT_2', + layer: ['HOST_1', 'SHELL_1'], + getDependencyAPIs: () => [MockAPI1], + declareAPIs: () => [MockAPI2], + attach(shell) { + shell.contributeAPI(MockAPI2, () => ({})) + } + } + + const EntryPointViolation: EntryPoint = { + name: 'MOCK_ENTRY_POINT_3', + layer: ['HOST_1', 'SHELL_0'], + getDependencyAPIs: () => [MockAPI2] + } + + const EntryPointWithShellLayersOnly: EntryPoint = { + name: 'MOCK_ENTRY_POINT_4', + layer: ['HOST_1', 'SHELL_0'], + getDependencyAPIs: () => [] + } + + const host = createAppHost([], { + ...emptyLoggerOptions, + layers: hostLayersDimension + }) + + expect(() => { + host.addShells([EntryPointWithShellLayersOnly]) + }).toThrowError(`Cannot find layer SHELL_0`) + + host.addShells([EntryPoint1]) + + expect(() => { + host.addShells([EntryPoint2]) + }).not.toThrow() + + expect(() => { + host.addShells([EntryPointViolation]) + }).toThrowError( + `Entry point ${EntryPointViolation.name} of layer SHELL_0 cannot depend on API ${MockAPI2.name} of layer SHELL_1` + ) + + /* + TBD - Should we detach entry points with layers defined by detached shells (?) + If so - should they stay on pending list? should we allow adding entry points with layers that are not defined? + (currently not really symmetrical with API dependencies behavior that are always pending if missing implementation) + */ + // host.addShells([EntryPointWithShellLayersOnly]) + // await host.removeShells([EntryPoint1.name]) + // expect(host.hasShell(EntryPointWithShellLayersOnly.name)).toBe(false) + }) }) describe('API version', () => { diff --git a/testKit/index.tsx b/testKit/index.tsx index 5bde90ce..02fc393e 100644 --- a/testKit/index.tsx +++ b/testKit/index.tsx @@ -191,6 +191,7 @@ function createShell(host: AppHost): PrivateShell { contributeState: _.noop, contributeObservableState: () => mockObservable(undefined as any), contributeMainView: _.noop, + contributeSubLayersDimension: _.noop, flushMemoizedForState: _.noop, memoizeForState: _.identity, memoize: _.identity,