Skip to content

Commit

Permalink
feat: Introduced Controller, useController() and mergeQueries()
Browse files Browse the repository at this point in the history
  • Loading branch information
mnasyrov committed Aug 3, 2021
1 parent 67ed094 commit d84a2e2
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 23 deletions.
56 changes: 56 additions & 0 deletions packages/rx-effects-react/src/useController.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 15 additions & 0 deletions packages/rx-effects-react/src/useController.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Controller<Record<string, any>>>(
factory: () => T,
dependencies?: unknown[],
): T {
const controller = useMemo(factory, dependencies);
useEffect(() => controller.destroy, [controller]);

return controller;
}
19 changes: 19 additions & 0 deletions packages/rx-effects/src/controller.ts
Original file line number Diff line number Diff line change
@@ -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<ControllerProps extends {} = {}> = Readonly<
{
/** Dispose the controller and clean its resources */
destroy: () => void;
} & ControllerProps
>;
4 changes: 2 additions & 2 deletions packages/rx-effects/src/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ export type EffectHandler<Event, Result> = (
event: Event,
) => Result | Promise<Result> | Observable<Result>;

export type HandlerOptions<ErrorType = Error> = {
export type HandlerOptions<ErrorType = Error> = Readonly<{
onSourceCompleted?: () => void;
onSourceFailed?: (error: ErrorType) => void;
};
}>;

export type EffectState<Event, Result = void, ErrorType = Error> = {
readonly result$: Observable<Result>;
Expand Down
25 changes: 19 additions & 6 deletions packages/rx-effects/src/effectScope.ts
Original file line number Diff line number Diff line change
@@ -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: <Event = void, Result = void, ErrorType = Error>(
createController: <ControllerProps>(
factory: () => Controller<ControllerProps>,
) => Controller<ControllerProps>;

createEffect: <Event = void, Result = void, ErrorType = Error>(
handler: EffectHandler<Event, Result>,
) => Effect<Event, Result, ErrorType>;

readonly handleAction: <Event, Result = void, ErrorType = Error>(
handleAction: <Event, Result = void, ErrorType = Error>(
source: Observable<Event> | Action<Event>,
handler: EffectHandler<Event, Result>,
options?: HandlerOptions<ErrorType>,
) => Effect<Event, Result, ErrorType>;
};
}>;

export function createEffectScope(): EffectScope {
const subscriptions = new Subscription();
Expand All @@ -30,6 +34,15 @@ export function createEffectScope(): EffectScope {
subscriptions.unsubscribe();
},

createController<ControllerProps>(
factory: () => Controller<ControllerProps>,
) {
const controller = factory();
subscriptions.add(controller.destroy);

return controller;
},

createEffect<Event, Result, ErrorType>(
handler: EffectHandler<Event, Result>,
) {
Expand Down
15 changes: 8 additions & 7 deletions packages/rx-effects/src/example.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -62,18 +63,18 @@ type ControllerEvents =
| { type: 'added'; value: number }
| { type: 'subtracted'; value: number };

function createCalculatorController(
store: CalculatorStore,
eventBus: Action<ControllerEvents>,
): {
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<ControllerEvents>,
): CalculatorController {
const scope = createEffectScope();

const incrementAction = createAction<void>();
Expand Down
1 change: 1 addition & 0 deletions packages/rx-effects/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 2 additions & 2 deletions packages/rx-effects/src/stateDeclaration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ export type StateFactory<State extends Record<string, unknown>> = (
values?: Partial<State>,
) => State;

export type StateDeclaration<State extends Record<string, unknown>> = {
export type StateDeclaration<State extends Record<string, unknown>> = Readonly<{
initialState: State;
createState: StateFactory<State>;
createStore: (initialState?: State) => Store<State>;
};
}>;

export function declareState<State extends Record<string, unknown>>(
stateFactory: StateFactory<State>,
Expand Down
39 changes: 35 additions & 4 deletions packages/rx-effects/src/stateQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>(0);
const sourceQuery: StateQuery<number> = {
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);
Expand All @@ -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' });
});
});
21 changes: 19 additions & 2 deletions packages/rx-effects/src/stateQuery.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Observable } from 'rxjs';
import { combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export type StateQuery<T> = {
readonly get: () => T;
readonly value$: Observable<T>;
};

export function mapStateQuery<T, R>(
export function mapQuery<T, R>(
query: StateQuery<T>,
mapper: (value: T) => R,
): StateQuery<R> {
Expand All @@ -15,3 +15,20 @@ export function mapStateQuery<T, R>(
value$: query.value$.pipe(map(mapper)),
};
}

export function mergeQueries<
Queries extends StateQuery<unknown>[],
Values extends {
[K in keyof Queries]: Queries[K] extends StateQuery<infer V> ? V : never;
},
Result,
>(queries: Queries, merger: (values: Values) => Result): StateQuery<Result> {
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$,
};
}

0 comments on commit d84a2e2

Please sign in to comment.