From 516b1f29266d5a2c623c063237e8e4e4aa681797 Mon Sep 17 00:00:00 2001 From: Shirly Niego Date: Thu, 10 Nov 2022 15:37:57 +0200 Subject: [PATCH 1/6] Observable chain --- src/API.ts | 13 ++++++++++++ src/appHost.tsx | 12 ++++++++++- src/throttledStore.tsx | 46 ++++++++++++++++++++++++++++++++++++++++++ testKit/index.tsx | 12 ++++++++++- 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/API.ts b/src/API.ts index 39b08e5a..ac60f263 100644 --- a/src/API.ts +++ b/src/API.ts @@ -1,5 +1,6 @@ import * as React from 'react' import * as Redux from 'redux' +import { ObservablesMap, ObservedSelectorsMap } from '.' import { ThrottledStore } from './throttledStore' export { AppHostAPI } from './appHostServices' @@ -417,6 +418,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: OM, + chainFunction: (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..b8b40bd4 100644 --- a/src/appHost.tsx +++ b/src/appHost.tsx @@ -37,6 +37,7 @@ import { AnyExtensionSlot, createExtensionSlot, createCustomExtensionSlot } from import { InstalledShellsActions, InstalledShellsSelectors, ShellToggleSet } from './installedShellsState' import { dependentAPIs, declaredAPIs } from './appHostUtils' import { + createChainObservable, createObservable, createThrottledStore, PrivateThrottledStore, @@ -48,7 +49,7 @@ import { ConsoleHostLogger, createShellLogger } from './loggers' import { monitorAPI } from './monitorAPI' import { Graph, Tarjan } from './tarjanGraph' import { setupDebugInfo } from './repluggableAppDebug' -import { ShellRenderer } from '.' +import { ObservablesMap, ObservedSelectorsMap, ShellRenderer } from '.' import { IterableWeakMap } from './IterableWeakMap' function isMultiArray(v: T[] | T[][]): v is T[][] { @@ -953,6 +954,15 @@ miss: ${memoizedWithMissHit.miss} return observable }, + contributeChainObservableState( + observablesDependencies: OM, + chainFunction: (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/throttledStore.tsx b/src/throttledStore.tsx index 8a5912f9..c5479707 100644 --- a/src/throttledStore.tsx +++ b/src/throttledStore.tsx @@ -6,6 +6,7 @@ import { AppHost, ExtensionSlot, ReducersMapObjectContributor, ObservableState, import { contributeInstalledShellsState } from './installedShellsState' import { interceptAnyObject } from './interceptAnyObject' import { invokeSlotCallbacks } from './invokeSlotCallbacks' +import { ObservablesMap, ObservedSelectorsMap } from './connectWithShell' type ReducerNotificationScope = 'broadcasting' | 'observable' interface ShellsReducersMap { @@ -268,3 +269,48 @@ export const createObservable = ( current: getOrCreateCachedSelector } } + +export const createChainObservable = ( + shell: Shell, + uniqueName: string, + observablesDependencies: OM, + chainFunction: (observedDependencies: ObservedSelectorsMap) => TChainSelector +): PrivateObservableState => { + const getDependenciesValues = (): ObservedSelectorsMap => { + return _.mapValues(observablesDependencies, observable => { + const selector = observable.current() + return selector + }) + } + let currentValue: TChainSelector = chainFunction(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(getDependenciesValues()) + notify() + }) + } + + return { + subscribe(fromShell, callback) { + observersSlot.contribute(fromShell, callback) + return () => { + observersSlot.discardBy(item => item.contribution === callback) + } + }, + notify, + current: () => { + currentValue = chainFunction(getDependenciesValues()) + return currentValue + } + } +} diff --git a/testKit/index.tsx b/testKit/index.tsx index ced2264b..7d1de2bc 100644 --- a/testKit/index.tsx +++ b/testKit/index.tsx @@ -2,7 +2,16 @@ import { mount, ReactWrapper } from 'enzyme' import _ from 'lodash' import React, { ReactElement } from 'react' import { EntryPoint, ObservableState, PrivateShell, ShellBoundaryAspect } from '../src/API' -import { AnySlotKey, AppHost, AppMainView, createAppHost as _createAppHost, EntryPointOrPackage, Shell, SlotKey } from '../src/index' +import { + AnySlotKey, + AppHost, + AppMainView, + createAppHost as _createAppHost, + EntryPointOrPackage, + ObservablesMap, + Shell, + SlotKey +} from '../src/index' import { ShellRenderer } from '../src/renderSlotComponents' import { createShellLogger } from '../src/loggers' import { emptyLoggerOptions } from './emptyLoggerOptions' @@ -227,6 +236,7 @@ function createShell(host: AppHost): PrivateShell { }, contributeState: _.noop, contributeObservableState: () => mockObservable(undefined as any), + contributeChainObservableState: () => mockObservable(undefined as any), contributeMainView: _.noop, flushMemoizedForState: _.noop, memoizeForState: _.identity, From 5fe56fef8e757a05873674f4fa62f91cc8c84cc7 Mon Sep 17 00:00:00 2001 From: Shirly Niego Date: Thu, 10 Nov 2022 16:27:29 +0200 Subject: [PATCH 2/6] Fix build --- src/API.ts | 15 +++++++++++---- src/appHost.tsx | 12 +++++++----- src/connectWithShell.tsx | 10 +--------- src/throttledStore.tsx | 13 +++++++++++-- test/connectWithShell.spec.tsx | 4 ++-- testKit/index.tsx | 13 ++----------- testKit/package.json | 2 +- 7 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/API.ts b/src/API.ts index ac60f263..c16b9f56 100644 --- a/src/API.ts +++ b/src/API.ts @@ -1,6 +1,5 @@ import * as React from 'react' import * as Redux from 'redux' -import { ObservablesMap, ObservedSelectorsMap } from '.' import { ThrottledStore } from './throttledStore' export { AppHostAPI } from './appHostServices' @@ -328,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 @@ -425,9 +432,9 @@ export interface Shell extends Pick} contributor * @return {TAPI} Observer object for subscribing to state changes. The observer can also be passed to {connectWithShell}. */ - contributeChainObservableState( - observablesDependencies: OM, - chainFunction: (observedDependencies: ObservedSelectorsMap) => TChainSelector + contributeChainObservableState( + observablesDependencies: OBM, + chainFunction: (observedDependencies: ObservedSelectorsMap) => TChainSelector ): ObservableState /** diff --git a/src/appHost.tsx b/src/appHost.tsx index b8b40bd4..82402bf0 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' @@ -49,7 +51,7 @@ import { ConsoleHostLogger, createShellLogger } from './loggers' import { monitorAPI } from './monitorAPI' import { Graph, Tarjan } from './tarjanGraph' import { setupDebugInfo } from './repluggableAppDebug' -import { ObservablesMap, ObservedSelectorsMap, ShellRenderer } from '.' +import { ShellRenderer } from '.' import { IterableWeakMap } from './IterableWeakMap' function isMultiArray(v: T[] | T[][]): v is T[][] { @@ -954,9 +956,9 @@ miss: ${memoizedWithMissHit.miss} return observable }, - contributeChainObservableState( - observablesDependencies: OM, - chainFunction: (observedDependencies: ObservedSelectorsMap) => TChainSelector + contributeChainObservableState( + observablesDependencies: OBM, + chainFunction: (observedDependencies: ObservedSelectorsMap) => TChainSelector ): ObservableState { const observableUniqueName = `${entryPoint.name}/observable_${nextObservableId++}` const observable = createChainObservable(shell, observableUniqueName, observablesDependencies, chainFunction) 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 c5479707..60d7b398 100644 --- a/src/throttledStore.tsx +++ b/src/throttledStore.tsx @@ -2,11 +2,20 @@ 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' -import { ObservablesMap, ObservedSelectorsMap } from './connectWithShell' type ReducerNotificationScope = 'broadcasting' | 'observable' interface ShellsReducersMap { diff --git a/test/connectWithShell.spec.tsx b/test/connectWithShell.spec.tsx index 43f7b515..aac88333 100644 --- a/test/connectWithShell.spec.tsx +++ b/test/connectWithShell.spec.tsx @@ -1,7 +1,7 @@ import _ from 'lodash' import React, { FunctionComponent, ReactElement, useEffect } from 'react' -import { AppHost, EntryPoint, Shell, SlotKey, ObservableState, AnySlotKey } from '../src/API' +import { AppHost, EntryPoint, Shell, SlotKey, ObservableState, AnySlotKey, ObservedSelectorsMap } from '../src/API' import { createAppHost, mockPackage, @@ -15,7 +15,7 @@ import { 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' interface MockPackageState { [mockShellStateKey]: MockState diff --git a/testKit/index.tsx b/testKit/index.tsx index 7d1de2bc..4eb7e300 100644 --- a/testKit/index.tsx +++ b/testKit/index.tsx @@ -1,17 +1,8 @@ import { mount, ReactWrapper } from 'enzyme' import _ from 'lodash' import React, { ReactElement } from 'react' -import { EntryPoint, ObservableState, PrivateShell, ShellBoundaryAspect } from '../src/API' -import { - AnySlotKey, - AppHost, - AppMainView, - createAppHost as _createAppHost, - EntryPointOrPackage, - ObservablesMap, - Shell, - SlotKey -} from '../src/index' +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' import { emptyLoggerOptions } from './emptyLoggerOptions' 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" } From cc3aadffba259393f358c8e7cbd3e61dec66eee9 Mon Sep 17 00:00:00 2001 From: Shirly Niego Date: Sun, 13 Nov 2022 12:00:10 +0200 Subject: [PATCH 3/6] Add first test --- test/chainObservable.spec.tsx | 165 ++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 test/chainObservable.spec.tsx diff --git a/test/chainObservable.spec.tsx b/test/chainObservable.spec.tsx new file mode 100644 index 00000000..ca36e4c7 --- /dev/null +++ b/test/chainObservable.spec.tsx @@ -0,0 +1,165 @@ +import { AnySlotKey, AppHost, EntryPoint, ObservableState, Shell, SlotKey } from '../src' +import _ from 'lodash' +import { createAppHost, renderInHost, withThrowOnError } from '../testKit' +import { ReactElement } from 'react' +import { ObservedSelectorsMap } from '../src/API' +import { AnyAction } from 'redux' +// import { AnyAction } from 'redux' + +const withDependencyAPIs = (ep: EntryPoint, deps: AnySlotKey[]): EntryPoint => { + return { + ...ep, + getDependencyAPIs: () => (ep.getDependencyAPIs ? [...ep.getDependencyAPIs(), ...deps] : deps) + } +} + +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('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 } + + 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 === 'SET_FIRST_OBSERVABLE' ? { 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 === 'SET_SECOND_OBSERVABLE' ? { secondObservableValue: action.value } : state + } + }), + state => { + return { + getSecondObservableValue: () => state.secondObservable.secondObservableValue + } + } + ) + shell.contributeAPI(SecondObservableAPI, () => ({ + observables: { + secondObservable + } + })) + } + } + + interface ObservedData { + firstObservable: ObservableState + secondObservable: ObservableState + } + type ObservedDataMap = ObservedSelectorsMap + + it('should update chain subscriber on dependency update', () => { + const { host, shell } = createMocks(withDependencyAPIs(entryPointFirstObservable, [SecondObservableAPI]), [ + entryPointSecondObservable + ]) + + const chainSpy = jest.fn() + const firstObservable = shell.getAPI(FirstObservableAPI).observables.firstObservable + const secondObservable = shell.getAPI(SecondObservableAPI).observables.secondObservable + + const chain = shell.contributeChainObservableState( + { firstObservable, secondObservable }, + (observedDependencies: ObservedDataMap) => { + return ( + observedDependencies.firstObservable.getFirstObservableValue() + + '**' + + observedDependencies.secondObservable.getSecondObservableValue() + ) + } + ) + + 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: 'SET_FIRST_OBSERVABLE', value: firstUpdate }, host) + + expectedValue = firstUpdate + separator + secondInitialValue + expect(chainSpy).toHaveBeenCalledWith(expectedValue) + expect(chain.current()).toBe(expectedValue) + + dispatchAndFlush({ type: 'SET_SECOND_OBSERVABLE', value: secondUpdate }, host) + + expectedValue = firstUpdate + separator + secondUpdate + expect(chainSpy).toHaveBeenCalledWith(expectedValue) + expect(chain.current()).toBe(expectedValue) + }) +}) From 9127c4af179ce1ba7d5a352fdf28d046214a9070 Mon Sep 17 00:00:00 2001 From: Shirly Niego Date: Sun, 13 Nov 2022 16:36:34 +0200 Subject: [PATCH 4/6] Add tests and refactor --- src/throttledStore.tsx | 1 - test/chainObservable.spec.tsx | 170 ++++++++++++++++++++++----------- test/connectWithShell.spec.tsx | 40 +------- test/utils.ts | 30 ++++++ 4 files changed, 149 insertions(+), 92 deletions(-) create mode 100644 test/utils.ts diff --git a/src/throttledStore.tsx b/src/throttledStore.tsx index 60d7b398..9a6555a6 100644 --- a/src/throttledStore.tsx +++ b/src/throttledStore.tsx @@ -318,7 +318,6 @@ export const createChainObservable = }, notify, current: () => { - currentValue = chainFunction(getDependenciesValues()) return currentValue } } diff --git a/test/chainObservable.spec.tsx b/test/chainObservable.spec.tsx index ca36e4c7..a21725d3 100644 --- a/test/chainObservable.spec.tsx +++ b/test/chainObservable.spec.tsx @@ -1,10 +1,7 @@ -import { AnySlotKey, AppHost, EntryPoint, ObservableState, Shell, SlotKey } from '../src' -import _ from 'lodash' -import { createAppHost, renderInHost, withThrowOnError } from '../testKit' -import { ReactElement } from 'react' -import { ObservedSelectorsMap } from '../src/API' -import { AnyAction } from 'redux' -// import { AnyAction } from 'redux' +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 { @@ -13,31 +10,6 @@ const withDependencyAPIs = (ep: EntryPoint, deps: AnySlotKey[]): EntryPoint => { } } -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('chainObservable', () => { interface FirstObservableState { firstObservable: { firstObservableValue: string } @@ -62,6 +34,25 @@ describe('chainObservable', () => { } 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 = '**' @@ -73,7 +64,7 @@ describe('chainObservable', () => { const firstObservable = shell.contributeObservableState( () => ({ firstObservable: (state = { firstObservableValue: firstInitialValue }, action) => { - return action.type === 'SET_FIRST_OBSERVABLE' ? { firstObservableValue: action.value } : state + return action.type === setFirstObservableType ? { firstObservableValue: action.value } : state } }), state => { @@ -97,7 +88,7 @@ describe('chainObservable', () => { const secondObservable = shell.contributeObservableState( () => ({ secondObservable: (state = { secondObservableValue: secondInitialValue }, action) => { - return action.type === 'SET_SECOND_OBSERVABLE' ? { secondObservableValue: action.value } : state + return action.type === setSecondObservableType ? { secondObservableValue: action.value } : state } }), state => { @@ -114,31 +105,42 @@ describe('chainObservable', () => { } } - interface ObservedData { - firstObservable: ObservableState - secondObservable: ObservableState + const chainEntryPoint: EntryPoint = { + name: 'Chain entry point', + getDependencyAPIs: () => [FirstObservableAPI, SecondObservableAPI], + declareAPIs: () => [ChainAPI], + attach(boundShell: Shell) { + const chain = chainTwoObservables(boundShell) + boundShell.contributeAPI(ChainAPI, () => ({ + chainedObservable: chain + })) + } } - type ObservedDataMap = ObservedSelectorsMap + + 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 }, (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 chainSpy = jest.fn() - const firstObservable = shell.getAPI(FirstObservableAPI).observables.firstObservable - const secondObservable = shell.getAPI(SecondObservableAPI).observables.secondObservable - - const chain = shell.contributeChainObservableState( - { firstObservable, secondObservable }, - (observedDependencies: ObservedDataMap) => { - return ( - observedDependencies.firstObservable.getFirstObservableValue() + - '**' + - observedDependencies.secondObservable.getSecondObservableValue() - ) - } - ) + const chain = chainTwoObservables(shell) const firstUpdate = 'first_update' const secondUpdate = 'second_update' @@ -150,16 +152,76 @@ describe('chainObservable', () => { let expectedValue = firstInitialValue + separator + secondInitialValue expect(chain.current()).toBe(expectedValue) - dispatchAndFlush({ type: 'SET_FIRST_OBSERVABLE', value: firstUpdate }, host) + dispatchAndFlush({ type: setFirstObservableType, value: firstUpdate }, host) expectedValue = firstUpdate + separator + secondInitialValue expect(chainSpy).toHaveBeenCalledWith(expectedValue) expect(chain.current()).toBe(expectedValue) - dispatchAndFlush({ type: 'SET_SECOND_OBSERVABLE', value: secondUpdate }, host) + 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 }, (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) + }) }) diff --git a/test/connectWithShell.spec.tsx b/test/connectWithShell.spec.tsx index aac88333..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 React, { FunctionComponent, useEffect } from 'react' import { AppHost, EntryPoint, Shell, SlotKey, ObservableState, AnySlotKey, ObservedSelectorsMap } from '../src/API' -import { - createAppHost, - mockPackage, - mockShellStateKey, - MockState, - renderInHost, - connectWithShell, - connectWithShellAndObserve, - withThrowOnError -} from '../testKit' +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 { 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() +} From 1c8a8bc78ac401ffe41ec53d1cc231ea03af69a5 Mon Sep 17 00:00:00 2001 From: Shirly Niego Date: Sun, 13 Nov 2022 17:23:08 +0200 Subject: [PATCH 5/6] Add shell --- src/API.ts | 2 +- src/throttledStore.tsx | 6 ++-- test/chainObservable.spec.tsx | 63 +++++++++++++++++++++++++++++------ 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/API.ts b/src/API.ts index c16b9f56..3c10caf7 100644 --- a/src/API.ts +++ b/src/API.ts @@ -434,7 +434,7 @@ export interface Shell extends Pick( observablesDependencies: OBM, - chainFunction: (observedDependencies: ObservedSelectorsMap) => TChainSelector + chainFunction: (shell: Shell, observedDependencies: ObservedSelectorsMap) => TChainSelector ): ObservableState /** diff --git a/src/throttledStore.tsx b/src/throttledStore.tsx index 9a6555a6..06f3872a 100644 --- a/src/throttledStore.tsx +++ b/src/throttledStore.tsx @@ -283,7 +283,7 @@ export const createChainObservable = shell: Shell, uniqueName: string, observablesDependencies: OM, - chainFunction: (observedDependencies: ObservedSelectorsMap) => TChainSelector + chainFunction: (shell: Shell, observedDependencies: ObservedSelectorsMap) => TChainSelector ): PrivateObservableState => { const getDependenciesValues = (): ObservedSelectorsMap => { return _.mapValues(observablesDependencies, observable => { @@ -291,7 +291,7 @@ export const createChainObservable = return selector }) } - let currentValue: TChainSelector = chainFunction(getDependenciesValues()) + let currentValue: TChainSelector = chainFunction(shell, getDependenciesValues()) const subscribersSlotKey: SlotKey> = { name: uniqueName @@ -304,7 +304,7 @@ export const createChainObservable = for (const key in observablesDependencies) { observablesDependencies[key].subscribe(shell, () => { - currentValue = chainFunction(getDependenciesValues()) + currentValue = chainFunction(shell, getDependenciesValues()) notify() }) } diff --git a/test/chainObservable.spec.tsx b/test/chainObservable.spec.tsx index a21725d3..e1ebf490 100644 --- a/test/chainObservable.spec.tsx +++ b/test/chainObservable.spec.tsx @@ -122,13 +122,16 @@ describe('chainObservable', () => { const firstObservable = shell.getAPI(FirstObservableAPI).observables.firstObservable const secondObservable = shell.getAPI(SecondObservableAPI).observables.secondObservable - return shell.contributeChainObservableState({ firstObservable, secondObservable }, (observedDependencies: ChainDependenciesMap) => { - return ( - observedDependencies.firstObservable.getFirstObservableValue() + - separator + - observedDependencies.secondObservable.getSecondObservableValue() - ) - }) + return shell.contributeChainObservableState( + { firstObservable, secondObservable }, + (chainShell: Shell, observedDependencies: ChainDependenciesMap) => { + return ( + observedDependencies.firstObservable.getFirstObservableValue() + + separator + + observedDependencies.secondObservable.getSecondObservableValue() + ) + } + ) } beforeEach(() => { @@ -170,9 +173,12 @@ describe('chainObservable', () => { const newChainEnding = '#$%' const observablesChain = host.getAPI(ChainAPI).chainedObservable - const newChain = shell.contributeChainObservableState({ observablesChain }, (observedDependencies: ObservedChainMap) => { - return observedDependencies.observablesChain + newChainEnding - }) + const newChain = shell.contributeChainObservableState( + { observablesChain }, + (chainShell: Shell, observedDependencies: ObservedChainMap) => { + return observedDependencies.observablesChain + newChainEnding + } + ) const newChainSpy = jest.fn() newChain.subscribe(shell, newValue => { @@ -224,4 +230,41 @@ describe('chainObservable', () => { 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) + }) }) From 4e6f17607115fa6a17539089c1b479b307897414 Mon Sep 17 00:00:00 2001 From: Shirly Niego Date: Sun, 13 Nov 2022 17:41:22 +0200 Subject: [PATCH 6/6] Fix build --- src/appHost.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appHost.tsx b/src/appHost.tsx index 82402bf0..5569b33d 100644 --- a/src/appHost.tsx +++ b/src/appHost.tsx @@ -958,7 +958,7 @@ miss: ${memoizedWithMissHit.miss} contributeChainObservableState( observablesDependencies: OBM, - chainFunction: (observedDependencies: ObservedSelectorsMap) => TChainSelector + chainFunction: (shell: Shell, observedDependencies: ObservedSelectorsMap) => TChainSelector ): ObservableState { const observableUniqueName = `${entryPoint.name}/observable_${nextObservableId++}` const observable = createChainObservable(shell, observableUniqueName, observablesDependencies, chainFunction)