diff --git a/src/API.ts b/src/API.ts index 39b08e5a..3c10caf7 100644 --- a/src/API.ts +++ b/src/API.ts @@ -327,6 +327,14 @@ export interface ObservableState { current(): TSelectorAPI } +export interface ObservablesMap { + [key: string]: ObservableState +} + +export type ObservedSelectorsMap = { + [K in keyof M]: M[K] extends ObservableState ? S : undefined +} + export type AnyFunction = (...args: any[]) => any export type FunctionWithSameArgs = (...args: Parameters) => any @@ -417,6 +425,18 @@ export interface Shell extends Pick TSelector ): ObservableState + /** + * ???? + * + * @template TState + * @param {ReducersMapObjectContributor} contributor + * @return {TAPI} Observer object for subscribing to state changes. The observer can also be passed to {connectWithShell}. + */ + contributeChainObservableState( + observablesDependencies: OBM, + chainFunction: (shell: Shell, observedDependencies: ObservedSelectorsMap) => TChainSelector + ): ObservableState + /** * Contribute the main view (root) of the application * Intended to be used by a single {Shell} in an application diff --git a/src/appHost.tsx b/src/appHost.tsx index 7df3c42e..5569b33d 100644 --- a/src/appHost.tsx +++ b/src/appHost.tsx @@ -29,7 +29,9 @@ import { APILayer, CustomExtensionSlotHandler, CustomExtensionSlot, - ObservableState + ObservableState, + ObservablesMap, + ObservedSelectorsMap } from './API' import _ from 'lodash' import { AppHostAPI, AppHostServicesProvider, createAppHostServicesEntryPoint } from './appHostServices' @@ -37,6 +39,7 @@ import { AnyExtensionSlot, createExtensionSlot, createCustomExtensionSlot } from import { InstalledShellsActions, InstalledShellsSelectors, ShellToggleSet } from './installedShellsState' import { dependentAPIs, declaredAPIs } from './appHostUtils' import { + createChainObservable, createObservable, createThrottledStore, PrivateThrottledStore, @@ -953,6 +956,15 @@ miss: ${memoizedWithMissHit.miss} return observable }, + contributeChainObservableState( + observablesDependencies: OBM, + chainFunction: (shell: Shell, observedDependencies: ObservedSelectorsMap) => TChainSelector + ): ObservableState { + const observableUniqueName = `${entryPoint.name}/observable_${nextObservableId++}` + const observable = createChainObservable(shell, observableUniqueName, observablesDependencies, chainFunction) + return observable + }, + getStore(): ScopedStore { return { dispatch: host.getStore().dispatch, diff --git a/src/connectWithShell.tsx b/src/connectWithShell.tsx index 65161563..ecd5329a 100644 --- a/src/connectWithShell.tsx +++ b/src/connectWithShell.tsx @@ -2,7 +2,7 @@ import _ from 'lodash' import React from 'react' import { connect as reduxConnect } from 'react-redux' import { Action, Dispatch } from 'redux' -import { AnyFunction, ObservableState, StateObserverUnsubscribe, PrivateShell, Shell } from './API' +import { AnyFunction, StateObserverUnsubscribe, PrivateShell, Shell, ObservablesMap, ObservedSelectorsMap } from './API' import { ErrorBoundary } from './errorBoundary' import { ShellContext } from './shellContext' import { StoreContext } from './storeContext' @@ -166,14 +166,6 @@ export function connectWithShell( } } -export interface ObservablesMap { - [key: string]: ObservableState -} - -export type ObservedSelectorsMap = { - [K in keyof M]: M[K] extends ObservableState ? S : undefined -} - export type OmitObservedSelectors = Omit export function mapObservablesToSelectors(map: M): ObservedSelectorsMap { diff --git a/src/throttledStore.tsx b/src/throttledStore.tsx index 8a5912f9..06f3872a 100644 --- a/src/throttledStore.tsx +++ b/src/throttledStore.tsx @@ -2,7 +2,17 @@ import { Reducer, createStore, Store, ReducersMapObject, combineReducers, AnyAct import { devToolsEnhancer } from 'redux-devtools-extension' import { AppHostServicesProvider } from './appHostServices' import _ from 'lodash' -import { AppHost, ExtensionSlot, ReducersMapObjectContributor, ObservableState, StateObserver, Shell, SlotKey } from './API' +import { + AppHost, + ExtensionSlot, + ReducersMapObjectContributor, + ObservableState, + StateObserver, + Shell, + SlotKey, + ObservablesMap, + ObservedSelectorsMap +} from './API' import { contributeInstalledShellsState } from './installedShellsState' import { interceptAnyObject } from './interceptAnyObject' import { invokeSlotCallbacks } from './invokeSlotCallbacks' @@ -268,3 +278,47 @@ export const createObservable = ( current: getOrCreateCachedSelector } } + +export const createChainObservable = ( + shell: Shell, + uniqueName: string, + observablesDependencies: OM, + chainFunction: (shell: Shell, observedDependencies: ObservedSelectorsMap) => TChainSelector +): PrivateObservableState => { + const getDependenciesValues = (): ObservedSelectorsMap => { + return _.mapValues(observablesDependencies, observable => { + const selector = observable.current() + return selector + }) + } + let currentValue: TChainSelector = chainFunction(shell, getDependenciesValues()) + + const subscribersSlotKey: SlotKey> = { + name: uniqueName + } + const observersSlot = shell.declareSlot(subscribersSlotKey) + + const notify = () => { + invokeSlotCallbacks(observersSlot, currentValue) + } + + for (const key in observablesDependencies) { + observablesDependencies[key].subscribe(shell, () => { + currentValue = chainFunction(shell, getDependenciesValues()) + notify() + }) + } + + return { + subscribe(fromShell, callback) { + observersSlot.contribute(fromShell, callback) + return () => { + observersSlot.discardBy(item => item.contribution === callback) + } + }, + notify, + current: () => { + return currentValue + } + } +} diff --git a/test/chainObservable.spec.tsx b/test/chainObservable.spec.tsx new file mode 100644 index 00000000..e1ebf490 --- /dev/null +++ b/test/chainObservable.spec.tsx @@ -0,0 +1,270 @@ +import { AnySlotKey, EntryPoint, ObservableState, observeWithShell, SlotKey } from '../src' +import { ObservedSelectorsMap, Shell } from '../src/API' +import { createMocks, dispatchAndFlush } from './utils' +import React, { FunctionComponent } from 'react' + +const withDependencyAPIs = (ep: EntryPoint, deps: AnySlotKey[]): EntryPoint => { + return { + ...ep, + getDependencyAPIs: () => (ep.getDependencyAPIs ? [...ep.getDependencyAPIs(), ...deps] : deps) + } +} + +describe('chainObservable', () => { + interface FirstObservableState { + firstObservable: { firstObservableValue: string } + } + interface SecondObservableState { + secondObservable: { secondObservableValue: string } + } + + interface FirstObservableAPI { + observables: { firstObservable: ObservableState } + } + interface FirstObservableSelectors { + getFirstObservableValue(): string + } + const FirstObservableAPI: SlotKey = { name: 'FIRST_OBSERVABLE_API', public: true } + + interface SecondObservableAPI { + observables: { secondObservable: ObservableState } + } + interface SecondObservableSelectors { + getSecondObservableValue(): string + } + const SecondObservableAPI: SlotKey = { name: 'SECOND_OBSERVABLE_API', public: true } + + interface ObservedChain { + observablesChain: ObservableState + } + type ObservedChainMap = ObservedSelectorsMap + + interface ChainDependencies { + firstObservable: ObservableState + secondObservable: ObservableState + } + type ChainDependenciesMap = ObservedSelectorsMap + + interface ChainAPI { + chainedObservable: ObservableState + } + const ChainAPI: SlotKey = { name: 'Chain API', public: true } + + const setFirstObservableType = 'SET_FIRST_OBSERVABLE' + const setSecondObservableType = 'SET_SECOND_OBSERVABLE' + + const firstInitialValue = 'first_init' + const secondInitialValue = 'second_init' + const separator = '**' + + const entryPointFirstObservable: EntryPoint = { + name: 'FIRST_OBSERVABLE_ENTRY_POINT', + declareAPIs: () => [FirstObservableAPI], + attach(shell) { + const firstObservable = shell.contributeObservableState( + () => ({ + firstObservable: (state = { firstObservableValue: firstInitialValue }, action) => { + return action.type === setFirstObservableType ? { firstObservableValue: action.value } : state + } + }), + state => { + return { + getFirstObservableValue: () => state.firstObservable.firstObservableValue + } + } + ) + shell.contributeAPI(FirstObservableAPI, () => ({ + observables: { + firstObservable + } + })) + } + } + + const entryPointSecondObservable: EntryPoint = { + name: 'SECOND_OBSERVABLE_ENTRY_POINT', + declareAPIs: () => [SecondObservableAPI], + attach(shell) { + const secondObservable = shell.contributeObservableState( + () => ({ + secondObservable: (state = { secondObservableValue: secondInitialValue }, action) => { + return action.type === setSecondObservableType ? { secondObservableValue: action.value } : state + } + }), + state => { + return { + getSecondObservableValue: () => state.secondObservable.secondObservableValue + } + } + ) + shell.contributeAPI(SecondObservableAPI, () => ({ + observables: { + secondObservable + } + })) + } + } + + const chainEntryPoint: EntryPoint = { + name: 'Chain entry point', + getDependencyAPIs: () => [FirstObservableAPI, SecondObservableAPI], + declareAPIs: () => [ChainAPI], + attach(boundShell: Shell) { + const chain = chainTwoObservables(boundShell) + boundShell.contributeAPI(ChainAPI, () => ({ + chainedObservable: chain + })) + } + } + + const chainSpy = jest.fn() + const chainTwoObservables = (shell: Shell) => { + const firstObservable = shell.getAPI(FirstObservableAPI).observables.firstObservable + const secondObservable = shell.getAPI(SecondObservableAPI).observables.secondObservable + + return shell.contributeChainObservableState( + { firstObservable, secondObservable }, + (chainShell: Shell, observedDependencies: ChainDependenciesMap) => { + return ( + observedDependencies.firstObservable.getFirstObservableValue() + + separator + + observedDependencies.secondObservable.getSecondObservableValue() + ) + } + ) + } + + beforeEach(() => { + chainSpy.mockClear() + }) + + it('should update chain subscriber on dependency update', () => { + const { host, shell } = createMocks(withDependencyAPIs(entryPointFirstObservable, [SecondObservableAPI]), [ + entryPointSecondObservable + ]) + + const chain = chainTwoObservables(shell) + + const firstUpdate = 'first_update' + const secondUpdate = 'second_update' + + chain.subscribe(shell, newChain => { + chainSpy(newChain) + }) + + let expectedValue = firstInitialValue + separator + secondInitialValue + expect(chain.current()).toBe(expectedValue) + + dispatchAndFlush({ type: setFirstObservableType, value: firstUpdate }, host) + + expectedValue = firstUpdate + separator + secondInitialValue + expect(chainSpy).toHaveBeenCalledWith(expectedValue) + expect(chain.current()).toBe(expectedValue) + + dispatchAndFlush({ type: setSecondObservableType, value: secondUpdate }, host) + + expectedValue = firstUpdate + separator + secondUpdate + expect(chainSpy).toHaveBeenCalledWith(expectedValue) + expect(chain.current()).toBe(expectedValue) + }) + + it('should be able to chain a chain', () => { + const { host, shell } = createMocks(entryPointFirstObservable, [entryPointSecondObservable, chainEntryPoint]) + const newChainEnding = '#$%' + + const observablesChain = host.getAPI(ChainAPI).chainedObservable + const newChain = shell.contributeChainObservableState( + { observablesChain }, + (chainShell: Shell, observedDependencies: ObservedChainMap) => { + return observedDependencies.observablesChain + newChainEnding + } + ) + + const newChainSpy = jest.fn() + newChain.subscribe(shell, newValue => { + newChainSpy(newValue) + }) + + expect(newChain.current()).toBe(firstInitialValue + separator + secondInitialValue + newChainEnding) + + dispatchAndFlush({ type: setSecondObservableType, value: 'Hello' }, host) + + const newChainedValue = firstInitialValue + separator + 'Hello' + newChainEnding + expect(newChainSpy).toHaveBeenCalledWith(newChainedValue) + expect(newChain.current()).toBe(newChainedValue) + }) + + it('should update component observing a chain', () => { + const renderSpyFunc = jest.fn() + const PureComp: FunctionComponent }>> = props => { + renderSpyFunc() + return ( +
+
{props.chain}
+
+ ) + } + + const { host, shell, renderInShellContext } = createMocks(entryPointFirstObservable, [entryPointSecondObservable, chainEntryPoint]) + + const ObservingComponent = observeWithShell( + { + chain: host.getAPI(ChainAPI).chainedObservable + }, + shell + )(PureComp) + + const { root } = renderInShellContext() + if (!root) { + throw new Error('Connected component failed to render') + } + + expect(root.find(ObservingComponent).find('#CHAIN').text()).toBe(firstInitialValue + separator + secondInitialValue) + expect(renderSpyFunc).toHaveBeenCalledTimes(1) + + dispatchAndFlush({ type: setFirstObservableType, value: 'UPDATE' }, host) + + expect(renderSpyFunc).toHaveBeenCalledTimes(2) + expect(root.find(ObservingComponent).find('#CHAIN').text()).toBe('UPDATE' + separator + secondInitialValue) + + dispatchAndFlush({ type: 'NonExisting', value: 'UPDATE' }, host) + expect(renderSpyFunc).toHaveBeenCalledTimes(2) + }) + + it('should allow using shell in chain function', () => { + interface BlaAPI { + addBla(str: string): string + } + const BlaAPI: SlotKey = { name: 'BlaAPI', public: true } + + const blaEntryPoint: EntryPoint = { + name: 'bla entry point', + declareAPIs: () => [BlaAPI], + attach: (entryPointShell: Shell) => { + entryPointShell.contributeAPI(BlaAPI, () => ({ + addBla: (str: string) => str + 'Bla' + })) + } + } + const { host, shell } = createMocks(withDependencyAPIs(blaEntryPoint, [FirstObservableAPI]), [entryPointFirstObservable]) + + const firstObservable = shell.getAPI(FirstObservableAPI).observables.firstObservable + + const blaChain = shell.contributeChainObservableState({ firstObservable }, (chainShell: Shell, observedDependencies) => { + const blaAPI = chainShell.getAPI(BlaAPI) + return blaAPI.addBla(observedDependencies.firstObservable.getFirstObservableValue()) + }) + + blaChain.subscribe(shell, newChain => { + chainSpy(newChain) + }) + + expect(blaChain.current()).toBe(firstInitialValue + 'Bla') + + dispatchAndFlush({ type: setFirstObservableType, value: 'update' }, host) + + const expectedValue = 'update' + 'Bla' + expect(chainSpy).toHaveBeenCalledWith(expectedValue) + expect(blaChain.current()).toBe(expectedValue) + }) +}) diff --git a/test/connectWithShell.spec.tsx b/test/connectWithShell.spec.tsx index 43f7b515..df27e1a5 100644 --- a/test/connectWithShell.spec.tsx +++ b/test/connectWithShell.spec.tsx @@ -1,21 +1,12 @@ import _ from 'lodash' -import React, { FunctionComponent, ReactElement, useEffect } from 'react' - -import { AppHost, EntryPoint, Shell, SlotKey, ObservableState, AnySlotKey } from '../src/API' -import { - createAppHost, - mockPackage, - mockShellStateKey, - MockState, - renderInHost, - connectWithShell, - connectWithShellAndObserve, - withThrowOnError -} from '../testKit' +import React, { FunctionComponent, useEffect } from 'react' + +import { AppHost, EntryPoint, Shell, SlotKey, ObservableState, AnySlotKey, ObservedSelectorsMap } from '../src/API' +import { mockPackage, mockShellStateKey, MockState, renderInHost, connectWithShell, connectWithShellAndObserve } from '../testKit' import { mount, ReactWrapper } from 'enzyme' -import { AnyAction } from 'redux' import { TOGGLE_MOCK_VALUE } from '../testKit/mockPackage' -import { ObservedSelectorsMap, observeWithShell } from '../src' +import { observeWithShell } from '../src' +import { createMocks, dispatchAndFlush } from './utils' interface MockPackageState { [mockShellStateKey]: MockState @@ -24,31 +15,6 @@ interface MockPackageState { const getMockShellState = (host: AppHost) => _.get(host.getStore().getState(), [mockPackage.name], null) const getValueFromState = (state: MockPackageState) => `${state[mockShellStateKey].mockValue}` -const createMocks = (entryPoint: EntryPoint, moreEntryPoints: EntryPoint[] = []) => { - let cachedShell: Shell | null = null - const wrappedPackage: EntryPoint = { - ...entryPoint, - attach(shell) { - _.invoke(entryPoint, 'attach', shell) - cachedShell = shell - } - } - - const host = createAppHost([wrappedPackage, ...moreEntryPoints], withThrowOnError()) - const getShell = () => cachedShell as Shell - - return { - host, - shell: getShell(), - renderInShellContext: (reactElement: ReactElement) => renderInHost(reactElement, host, getShell()) - } -} - -const dispatchAndFlush = (action: AnyAction, { getStore }: AppHost) => { - getStore().dispatch(action) - getStore().flush() -} - describe('connectWithShell', () => { it('should pass exact shell to mapStateToProps', () => { const { shell, renderInShellContext } = createMocks(mockPackage) diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 00000000..059ad788 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,30 @@ +import { AppHost, EntryPoint, Shell } from '../src' +import _ from 'lodash' +import { createAppHost, renderInHost, withThrowOnError } from '../testKit' +import { ReactElement } from 'react' +import { AnyAction } from 'redux' + +export const createMocks = (entryPoint: EntryPoint, moreEntryPoints: EntryPoint[] = []) => { + let cachedShell: Shell | null = null + const wrappedPackage: EntryPoint = { + ...entryPoint, + attach(shell) { + _.invoke(entryPoint, 'attach', shell) + cachedShell = shell + } + } + + const host = createAppHost([wrappedPackage, ...moreEntryPoints], withThrowOnError()) + const getShell = () => cachedShell as Shell + + return { + host, + shell: getShell(), + renderInShellContext: (reactElement: ReactElement) => renderInHost(reactElement, host, getShell()) + } +} + +export const dispatchAndFlush = (action: AnyAction, { getStore }: AppHost) => { + getStore().dispatch(action) + getStore().flush() +} diff --git a/testKit/index.tsx b/testKit/index.tsx index ced2264b..4eb7e300 100644 --- a/testKit/index.tsx +++ b/testKit/index.tsx @@ -1,7 +1,7 @@ import { mount, ReactWrapper } from 'enzyme' import _ from 'lodash' import React, { ReactElement } from 'react' -import { EntryPoint, ObservableState, PrivateShell, ShellBoundaryAspect } from '../src/API' +import { EntryPoint, ObservablesMap, ObservableState, PrivateShell, ShellBoundaryAspect } from '../src/API' import { AnySlotKey, AppHost, AppMainView, createAppHost as _createAppHost, EntryPointOrPackage, Shell, SlotKey } from '../src/index' import { ShellRenderer } from '../src/renderSlotComponents' import { createShellLogger } from '../src/loggers' @@ -227,6 +227,7 @@ function createShell(host: AppHost): PrivateShell { }, contributeState: _.noop, contributeObservableState: () => mockObservable(undefined as any), + contributeChainObservableState: () => mockObservable(undefined as any), contributeMainView: _.noop, flushMemoizedForState: _.noop, memoizeForState: _.identity, diff --git a/testKit/package.json b/testKit/package.json index b1993e27..a7db6b52 100644 --- a/testKit/package.json +++ b/testKit/package.json @@ -1,3 +1,3 @@ { - "main": "../dist/testKit/index.js" + "main": "./index.tsx" }