From d84a2e2b8d1f57ca59e9664004de844a1f8bcf1f Mon Sep 17 00:00:00 2001 From: Mikhail Nasyrov Date: Tue, 3 Aug 2021 23:13:24 +0700 Subject: [PATCH] feat: Introduced `Controller`, `useController()` and `mergeQueries()` --- .../src/useController.test.ts | 56 +++++++++++++++++++ .../rx-effects-react/src/useController.ts | 15 +++++ packages/rx-effects/src/controller.ts | 19 +++++++ packages/rx-effects/src/effect.ts | 4 +- packages/rx-effects/src/effectScope.ts | 25 +++++++-- packages/rx-effects/src/example.test.ts | 15 ++--- packages/rx-effects/src/index.ts | 1 + packages/rx-effects/src/stateDeclaration.ts | 4 +- packages/rx-effects/src/stateQuery.test.ts | 39 +++++++++++-- packages/rx-effects/src/stateQuery.ts | 21 ++++++- 10 files changed, 176 insertions(+), 23 deletions(-) create mode 100644 packages/rx-effects-react/src/useController.test.ts create mode 100644 packages/rx-effects-react/src/useController.ts create mode 100644 packages/rx-effects/src/controller.ts diff --git a/packages/rx-effects-react/src/useController.test.ts b/packages/rx-effects-react/src/useController.test.ts new file mode 100644 index 0000000..09cbe56 --- /dev/null +++ b/packages/rx-effects-react/src/useController.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { Controller } from 'rx-effects'; +import { useController } from './useController'; + +describe('useController()', () => { + it('should create a controller by the factory and destroy it on unmount', () => { + const action = jest.fn(); + const destroy = jest.fn(); + + function createController(): Controller<{ + value: number; + action: () => void; + }> { + return { value: 1, action, destroy }; + } + + const { result, unmount } = renderHook(() => + useController(createController), + ); + + expect(result.current.value).toBe(1); + + result.current.action(); + expect(action).toBeCalledTimes(1); + + unmount(); + expect(destroy).toBeCalledTimes(1); + }); + + it('should recreate the controller if a dependency is changed', () => { + const destroy = jest.fn(); + + const createController = (value: number) => ({ value, destroy }); + + const { result, rerender, unmount } = renderHook( + ({ value }) => useController(() => createController(value), [value]), + { initialProps: { value: 1 } }, + ); + + const controller1 = result.current; + expect(controller1.value).toBe(1); + + rerender({ value: 1 }); + const controller2 = result.current; + expect(controller2).toBe(controller1); + expect(controller2.value).toBe(1); + + rerender({ value: 2 }); + const controller3 = result.current; + expect(controller3).not.toBe(controller2); + expect(controller3.value).toBe(2); + + unmount(); + expect(destroy).toBeCalledTimes(2); + }); +}); diff --git a/packages/rx-effects-react/src/useController.ts b/packages/rx-effects-react/src/useController.ts new file mode 100644 index 0000000..360a665 --- /dev/null +++ b/packages/rx-effects-react/src/useController.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { useEffect, useMemo } from 'react'; +import { Controller } from 'rx-effects'; + +export function useController>>( + factory: () => T, + dependencies?: unknown[], +): T { + const controller = useMemo(factory, dependencies); + useEffect(() => controller.destroy, [controller]); + + return controller; +} diff --git a/packages/rx-effects/src/controller.ts b/packages/rx-effects/src/controller.ts new file mode 100644 index 0000000..3c90a33 --- /dev/null +++ b/packages/rx-effects/src/controller.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ + +/** + * Business logic controller + * + * @example + * ```ts + * type LoggerController = Controller<{ + * log: (message: string) => void; + * }>; + * ``` + */ +export type Controller = Readonly< + { + /** Dispose the controller and clean its resources */ + destroy: () => void; + } & ControllerProps +>; diff --git a/packages/rx-effects/src/effect.ts b/packages/rx-effects/src/effect.ts index 3892b9f..3a7803b 100644 --- a/packages/rx-effects/src/effect.ts +++ b/packages/rx-effects/src/effect.ts @@ -8,10 +8,10 @@ export type EffectHandler = ( event: Event, ) => Result | Promise | Observable; -export type HandlerOptions = { +export type HandlerOptions = Readonly<{ onSourceCompleted?: () => void; onSourceFailed?: (error: ErrorType) => void; -}; +}>; export type EffectState = { readonly result$: Observable; diff --git a/packages/rx-effects/src/effectScope.ts b/packages/rx-effects/src/effectScope.ts index 0197f04..19e8001 100644 --- a/packages/rx-effects/src/effectScope.ts +++ b/packages/rx-effects/src/effectScope.ts @@ -1,22 +1,26 @@ import { Observable, Subscription, TeardownLogic } from 'rxjs'; import { Action } from './action'; +import { Controller } from './controller'; import { createEffect, Effect, EffectHandler, HandlerOptions } from './effect'; import { handleAction } from './handleAction'; -export type EffectScope = { - readonly add: (teardown: TeardownLogic) => void; - readonly destroy: () => void; +export type EffectScope = Controller<{ + add: (teardown: TeardownLogic) => void; - readonly createEffect: ( + createController: ( + factory: () => Controller, + ) => Controller; + + createEffect: ( handler: EffectHandler, ) => Effect; - readonly handleAction: ( + handleAction: ( source: Observable | Action, handler: EffectHandler, options?: HandlerOptions, ) => Effect; -}; +}>; export function createEffectScope(): EffectScope { const subscriptions = new Subscription(); @@ -30,6 +34,15 @@ export function createEffectScope(): EffectScope { subscriptions.unsubscribe(); }, + createController( + factory: () => Controller, + ) { + const controller = factory(); + subscriptions.add(controller.destroy); + + return controller; + }, + createEffect( handler: EffectHandler, ) { diff --git a/packages/rx-effects/src/example.test.ts b/packages/rx-effects/src/example.test.ts index 5877af8..e88b66a 100644 --- a/packages/rx-effects/src/example.test.ts +++ b/packages/rx-effects/src/example.test.ts @@ -1,6 +1,7 @@ import { firstValueFrom } from 'rxjs'; import { take, toArray } from 'rxjs/operators'; import { Action, createAction } from './action'; +import { Controller } from './controller'; import { Effect } from './effect'; import { createEffectScope, EffectScope } from './effectScope'; import { declareState, StateDeclaration } from './stateDeclaration'; @@ -62,18 +63,18 @@ type ControllerEvents = | { type: 'added'; value: number } | { type: 'subtracted'; value: number }; -function createCalculatorController( - store: CalculatorStore, - eventBus: Action, -): { - destroy: () => void; - +type CalculatorController = Controller<{ increment: () => void; decrement: () => void; sum: (value: number) => void; subtract: (value: number) => void; reset: () => void; -} { +}>; + +function createCalculatorController( + store: CalculatorStore, + eventBus: Action, +): CalculatorController { const scope = createEffectScope(); const incrementAction = createAction(); diff --git a/packages/rx-effects/src/index.ts b/packages/rx-effects/src/index.ts index e54ef3f..d46615d 100644 --- a/packages/rx-effects/src/index.ts +++ b/packages/rx-effects/src/index.ts @@ -1,6 +1,7 @@ export * from './action'; export * from './effect'; export * from './effectScope'; +export * from './controller'; export * from './handleAction'; export * from './stateDeclaration'; export * from './stateMutation'; diff --git a/packages/rx-effects/src/stateDeclaration.ts b/packages/rx-effects/src/stateDeclaration.ts index fc38126..b63f743 100644 --- a/packages/rx-effects/src/stateDeclaration.ts +++ b/packages/rx-effects/src/stateDeclaration.ts @@ -4,11 +4,11 @@ export type StateFactory> = ( values?: Partial, ) => State; -export type StateDeclaration> = { +export type StateDeclaration> = Readonly<{ initialState: State; createState: StateFactory; createStore: (initialState?: State) => Store; -}; +}>; export function declareState>( stateFactory: StateFactory, diff --git a/packages/rx-effects/src/stateQuery.test.ts b/packages/rx-effects/src/stateQuery.test.ts index 794d03d..20a7b86 100644 --- a/packages/rx-effects/src/stateQuery.test.ts +++ b/packages/rx-effects/src/stateQuery.test.ts @@ -1,16 +1,17 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs'; -import { mapStateQuery, StateQuery } from './stateQuery'; +import { mapQuery, mergeQueries, StateQuery } from './stateQuery'; +import { createStore } from './store'; -describe('mapStateQuery()', () => { - it('should returns a new state query with applied mapper which transforms the selected value', async () => { +describe('mapQuery()', () => { + it('should return a new state query with applied mapper which transforms the selected value', async () => { const sourceValue$ = new BehaviorSubject(0); const sourceQuery: StateQuery = { get: () => sourceValue$.getValue(), value$: sourceValue$, }; - const query = mapStateQuery(sourceQuery, (value) => value + 10); + const query = mapQuery(sourceQuery, (value) => value + 10); expect(query.get()).toBe(10); expect(await firstValueFrom(query.value$)).toBe(10); @@ -20,3 +21,33 @@ describe('mapStateQuery()', () => { expect(await firstValueFrom(query.value$)).toBe(11); }); }); + +describe('mergeQueries()', () => { + it('should return a calculated value from source queries', () => { + const store1 = createStore(2); + const store2 = createStore('text'); + const query = mergeQueries([store1, store2], ([a, b]) => ({ a, b })); + + expect(query.get()).toEqual({ a: 2, b: 'text' }); + + store1.set(3); + expect(query.get()).toEqual({ a: 3, b: 'text' }); + + store2.set('text2'); + expect(query.get()).toEqual({ a: 3, b: 'text2' }); + }); + + it('should return an observable with the calculated value from source queries', async () => { + const store1 = createStore(2); + const store2 = createStore('text'); + const query = mergeQueries([store1, store2], ([a, b]) => ({ a, b })); + + expect(await firstValueFrom(query.value$)).toEqual({ a: 2, b: 'text' }); + + store1.set(3); + expect(await firstValueFrom(query.value$)).toEqual({ a: 3, b: 'text' }); + + store2.set('text2'); + expect(await firstValueFrom(query.value$)).toEqual({ a: 3, b: 'text2' }); + }); +}); diff --git a/packages/rx-effects/src/stateQuery.ts b/packages/rx-effects/src/stateQuery.ts index 25f663d..60b7601 100644 --- a/packages/rx-effects/src/stateQuery.ts +++ b/packages/rx-effects/src/stateQuery.ts @@ -1,4 +1,4 @@ -import { Observable } from 'rxjs'; +import { combineLatest, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; export type StateQuery = { @@ -6,7 +6,7 @@ export type StateQuery = { readonly value$: Observable; }; -export function mapStateQuery( +export function mapQuery( query: StateQuery, mapper: (value: T) => R, ): StateQuery { @@ -15,3 +15,20 @@ export function mapStateQuery( value$: query.value$.pipe(map(mapper)), }; } + +export function mergeQueries< + Queries extends StateQuery[], + Values extends { + [K in keyof Queries]: Queries[K] extends StateQuery ? V : never; + }, + Result, +>(queries: Queries, merger: (values: Values) => Result): StateQuery { + const value$ = combineLatest(queries.map((query) => query.value$)).pipe( + map((values) => merger(values as Values)), + ); + + return { + get: () => merger(queries.map((query) => query.get()) as Values), + value$, + }; +}