diff --git a/examples/simpleActions.tests.ts b/examples/actions.tests.ts similarity index 94% rename from examples/simpleActions.tests.ts rename to examples/actions.tests.ts index a106e2be..c88ba681 100644 --- a/examples/simpleActions.tests.ts +++ b/examples/actions.tests.ts @@ -4,8 +4,8 @@ import { map } from 'rxjs/operators'; import { actionCreator, ExtractPayload } from 'rxbeach'; import { ofType, extractPayload } from 'rxbeach/operators'; -describe('example', function() { - describe('simple actions', function() { +export default function actionExamples() { + describe('actions', function() { const voidAction = actionCreator('[test] void action'); const primitiveAction = actionCreator('[test] primitive action'); type Payload = { foo: number }; @@ -45,4 +45,4 @@ describe('example', function() { const payload_assignable_to_extracted: Extracted = (null as any) as Payload; const extracted_assignable_to_payload: Payload = (null as any) as Extracted; }); -}); +} diff --git a/examples/index.tests.ts b/examples/index.tests.ts new file mode 100644 index 00000000..ff843b57 --- /dev/null +++ b/examples/index.tests.ts @@ -0,0 +1,7 @@ +import actionExamples from './actions.tests'; +import namespaceExamples from './namespace.tests'; + +describe('examples', function() { + actionExamples(); + namespaceExamples(); +}); diff --git a/examples/namespace.tests.ts b/examples/namespace.tests.ts new file mode 100644 index 00000000..ccfa5905 --- /dev/null +++ b/examples/namespace.tests.ts @@ -0,0 +1,108 @@ +import { equal, deepEqual } from 'assert'; +import { of, Subject } from 'rxjs'; +import { reduce } from 'rxjs/operators'; +import { + actionCreator, + namespaceActionCreator, + namespaceActionDispatcher, +} from 'rxbeach'; +import { withNamespace } from 'rxbeach/operators'; +import { AnyAction, mockAction } from 'rxbeach/internal'; + +export default function namespaceExamples() { + describe('namespaces', function() { + const testAction = actionCreator('[test] primitive action'); + const namespaceA = 'A'; + const namespaceB = 'B'; + + describe('namespacing action creators', function() { + const testActionA = namespaceActionCreator(namespaceA, testAction); + const testActionB = namespaceActionCreator(namespaceB, testAction); + const actionObjectA = testActionA(1); + const actionObjectB = testActionB(2); + + let lastActionNamespaceA: AnyAction | undefined; + let lastActionNamespaceB: AnyAction | undefined; + let sumAllNamespaces: number | undefined; + this.afterEach(async function() { + lastActionNamespaceA = undefined; + lastActionNamespaceB = undefined; + sumAllNamespaces = undefined; + }); + + this.beforeEach(async function() { + const action$ = of(actionObjectA, actionObjectB); + const actionA$ = action$.pipe(withNamespace(namespaceA)); + const actionB$ = action$.pipe(withNamespace(namespaceB)); + const sum$ = action$.pipe(reduce((a, b) => a + (b.payload || 0), 0)); + + lastActionNamespaceA = await actionA$.toPromise(); + lastActionNamespaceB = await actionB$.toPromise(); + sumAllNamespaces = await sum$.toPromise(); + }); + + it('can filter namespace A', async function() { + equal(lastActionNamespaceA, actionObjectA); + }); + it('can filter namespace B', async function() { + equal(lastActionNamespaceB, actionObjectB); + }); + it('dispatches to main action$', async function() { + equal(sumAllNamespaces, 3); + }); + }); + + describe('namespacing action dispatchers', function() { + let lastActionNamespaceA: AnyAction | undefined; + let lastActionNamespaceB: AnyAction | undefined; + let sumAllNamespaces: number | undefined; + this.afterEach(function() { + lastActionNamespaceA = undefined; + lastActionNamespaceB = undefined; + sumAllNamespaces = undefined; + }); + + this.beforeEach(async function() { + const action$ = new Subject(); + const dispatchAction = action$.next.bind(action$); + + const dispatchA = namespaceActionDispatcher(namespaceA, dispatchAction); + const dispatchB = namespaceActionDispatcher(namespaceB, dispatchAction); + + const a_p = action$.pipe(withNamespace(namespaceA)).toPromise(); + const b_p = action$.pipe(withNamespace(namespaceB)).toPromise(); + const sum_p = action$ + .pipe(reduce((a: any, b: any) => a + (b.payload || 0), 0)) + .toPromise(); + + dispatchA(testAction(1)); + dispatchB(testAction(2)); + action$.complete(); + + const [a, b, sum] = await Promise.all([a_p, b_p, sum_p]); + + lastActionNamespaceA = a; + lastActionNamespaceB = b; + sumAllNamespaces = sum; + }); + + it('applies namespace A', function() { + deepEqual( + lastActionNamespaceA, + mockAction(testAction.type, namespaceA, 1) + ); + }); + + it('applies namespace B', function() { + deepEqual( + lastActionNamespaceB, + mockAction(testAction.type, namespaceB, 2) + ); + }); + + it('dispatches to root action$', function() { + equal(sumAllNamespaces, 3); + }); + }); + }); +} diff --git a/package.json b/package.json index d3a4cb6c..697c1663 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "private": true, "license": "MIT", "scripts": { - "test": "ts-mocha --paths -p tsconfig.json src/**/*.tests.ts examples/**/*.tests.ts", + "test": "ts-mocha --paths -p tsconfig.json src/*.tests.ts src/**/*.tests.ts examples/**/*.tests.ts", "build": "tsc -p tsconfig.json", "lint": "eslint --ext .ts src examples" }, diff --git a/src/actionCreator.ts b/src/actionCreator.ts index 08dde534..30d58fb7 100644 --- a/src/actionCreator.ts +++ b/src/actionCreator.ts @@ -25,9 +25,7 @@ export function actionCreator(type: string): UnknownActionCreator { const action = (payload?: any) => ({ type, payload, - meta: { - qualifiers: [], - }, + meta: {}, }); action.type = type; diff --git a/src/index.ts b/src/index.ts index 060f8ee3..483e971e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,3 +16,5 @@ export { } from './types/helpers'; export { actionCreator } from './actionCreator'; + +export { namespaceActionCreator, namespaceActionDispatcher } from './namespace'; diff --git a/src/internal/index.ts b/src/internal/index.ts index 20abdc8c..8a506433 100644 --- a/src/internal/index.ts +++ b/src/internal/index.ts @@ -1,4 +1,4 @@ -export { actionWithoutPayload, actionWithPayload } from './testUtils'; +export { mockAction } from './testUtils'; export { VoidPayload, AnyAction, diff --git a/src/internal/testUtils.ts b/src/internal/testUtils.ts index cf0d9441..3fff6802 100644 --- a/src/internal/testUtils.ts +++ b/src/internal/testUtils.ts @@ -1,18 +1,13 @@ -import { ActionWithPayload, ActionWithoutPayload } from 'types/Action'; +import { Action } from 'rxbeach'; +import { VoidPayload } from 'rxbeach/internal'; -export const actionWithoutPayload = ( +export const mockAction =

( type: string, - qualifiers: string[] = [] -): ActionWithoutPayload => ({ - meta: { qualifiers }, - type, -}); - -export const actionWithPayload =

( - type: string, - payload: P, - qualifiers: string[] = [] -): ActionWithPayload

=> ({ - ...actionWithoutPayload(type, qualifiers), - payload, -}); + namespace?: string, + payload?: P +): Action

=> + ({ + meta: { namespace }, + type, + payload, + } as Action

); diff --git a/src/namespace.tests.ts b/src/namespace.tests.ts new file mode 100644 index 00000000..8e91090e --- /dev/null +++ b/src/namespace.tests.ts @@ -0,0 +1,51 @@ +import { deepEqual } from 'assert'; +import { + namespaceActionCreator, + ActionDispatcher, + namespaceActionDispatcher, +} from 'rxbeach'; +import { mockAction, AnyAction } from 'rxbeach/internal'; + +describe('namespace', function() { + describe('namespaceActionCreator', function() { + const type = 'action type'; + const namespace = 'new namespace'; + const actionCreator = (payload: number) => + mockAction(type, 'old namespace', payload); + actionCreator.type = type; + + const namespacedActionCreator = namespaceActionCreator( + namespace, + actionCreator + ); + + const actionObject = namespacedActionCreator(12); + + it('Should create actions with namespace', function() { + deepEqual(actionObject, mockAction(type, namespace, 12)); + }); + }); + + describe('namespaceActionDispatcher', function() { + let dispatchedAction: AnyAction | undefined; + const parentDispatcher: ActionDispatcher = action => + (dispatchedAction = action); + + const namespace = 'new namespace'; + const childDispatcher = namespaceActionDispatcher( + namespace, + parentDispatcher + ); + + const actionObject = mockAction('action', 'old namespace'); + + childDispatcher(actionObject); + + it('Should invoke the parent dispatcher with namespaced actions', function() { + deepEqual(dispatchedAction, { + payload: undefined, + ...mockAction(actionObject.type, namespace), + }); + }); + }); +}); diff --git a/src/namespace.ts b/src/namespace.ts new file mode 100644 index 00000000..5b19c298 --- /dev/null +++ b/src/namespace.ts @@ -0,0 +1,60 @@ +import { ActionCreator, ActionDispatcher } from 'rxbeach'; +import { UnknownAction } from 'rxbeach/internal'; + +const _namespaceAction = (namespace: string, action: UnknownAction) => ({ + type: action.type, + payload: action.payload, + meta: { + ...action.meta, + namespace, + }, +}); + +/** + * Decorate an action creator so the created actions have the given namespace + * + * The given namespace will replace existing namespaces on the action objects. + * + * In contrast to `namespaceActionDispatcher`, the function returned by this + * function creates an action object instead of dispatching it. + * + * @see namespaceActionDispatcher + * @param namespace The namespace to set for the created actions + * @param actionCreator The action creator to decorate + * @returns An action creator that creates actions using the passed action + * creator, and sets the given namespace + */ +export const namespaceActionCreator = ( + namespace: string, + actionCreator: ActionCreator +): ActionCreator => { + const creator = (payload?: any) => + _namespaceAction(namespace, actionCreator(payload)); + creator.type = actionCreator.type; + + return creator as ActionCreator; +}; + +/** + * Decorate an action dispatcher so it dispatches namespaced actions + * + * The given namespace is set as the namespace for each action dispatched with + * this function, before the action is dispatched with the action dispatcher + * from the arguments. + * + * In contrast to `namespaceActionCreator`, the function returned by this + * function dispatches the action, instead of creating it. + * + * @see namespaceActionCreator + * @param parentDispatcher The dispatcher the returned action dispatcher will + * dispatch to + * @param namespace The namespace that will be set for each action before they + * are passed on to the parent dispatcher + * @returns An action dispatcher that sets the namespace before passing the + * actions to the parent dispatcher + */ +export const namespaceActionDispatcher = ( + namespace: string, + parentDispatcher: ActionDispatcher +): ActionDispatcher => action => + parentDispatcher(_namespaceAction(namespace, action)); diff --git a/src/operators/index.ts b/src/operators/index.ts index d4814f32..e401e0af 100644 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -1 +1 @@ -export { ofType, extractPayload } from './operators'; +export { ofType, extractPayload, withNamespace } from './operators'; diff --git a/src/operators/operators.tests.ts b/src/operators/operators.tests.ts index eaf2f394..4f5fac8b 100644 --- a/src/operators/operators.tests.ts +++ b/src/operators/operators.tests.ts @@ -1,15 +1,16 @@ import { equal, deepEqual } from 'assert'; import { of, OperatorFunction } from 'rxjs'; -import { tap, reduce } from 'rxjs/operators'; +import { reduce } from 'rxjs/operators'; import { ActionWithPayload, ActionWithoutPayload } from 'rxbeach'; import { extractPayload, ofType } from 'rxbeach/operators'; -import { actionWithPayload, actionWithoutPayload } from 'rxbeach/internal'; +import { mockAction } from 'rxbeach/internal'; +import { withNamespace } from './operators'; const pipeActionWithPayload = ( payload: P, pipe: OperatorFunction, R> ): Promise => - of(actionWithPayload('', payload)) + of(mockAction('', '', payload) as ActionWithPayload

) .pipe(pipe) .toPromise(); @@ -35,16 +36,15 @@ describe('operators', function() { const targetType = 'Correct type'; const otherType = 'Wrong type'; - await of( - actionWithoutPayload(targetType), - actionWithoutPayload(otherType), - actionWithoutPayload(targetType) + const res = await of( + mockAction(targetType), + mockAction(otherType), + mockAction(targetType) ) - .pipe( - ofType(targetType), - tap(action => equal(action.type, targetType)) - ) + .pipe(ofType(targetType)) .toPromise(); + + equal(res.type, targetType); }); it('Should filter multiple action types', async function() { @@ -53,9 +53,9 @@ describe('operators', function() { const otherType = 'Wrong type'; const collectedTypes = await of( - actionWithoutPayload(targetType1), - actionWithoutPayload(otherType), - actionWithoutPayload(targetType2) + mockAction(targetType1), + mockAction(otherType), + mockAction(targetType2) ) .pipe( ofType(targetType1, targetType2), @@ -66,4 +66,21 @@ describe('operators', function() { deepEqual(collectedTypes, [targetType1, targetType2]); }); }); + + describe('withNamespace', function() { + it('Should filter actions by namespace', async function() { + const actionType = 'actionType'; + const namespace = 'namespace'; + + const res = await of( + mockAction(actionType), + mockAction(actionType, namespace), + mockAction(actionType) + ) + .pipe(withNamespace(namespace)) + .toPromise(); + + deepEqual(res, mockAction(actionType, namespace)); + }); + }); }); diff --git a/src/operators/operators.ts b/src/operators/operators.ts index c88b1409..ef4cd9e0 100644 --- a/src/operators/operators.ts +++ b/src/operators/operators.ts @@ -1,6 +1,6 @@ import { OperatorFunction, MonoTypeOperatorFunction } from 'rxjs'; import { map, filter } from 'rxjs/operators'; -import { ActionWithPayload } from 'rxbeach'; +import { ActionWithPayload, Action } from 'rxbeach'; import { AnyAction } from 'rxbeach/internal'; //// Routines //// @@ -39,3 +39,13 @@ export const extractPayload = (): OperatorFunction< ActionWithPayload, Payload > => map(action => action.payload); + +/** + * Stream operator that filters for actions with the correct namespace + * + * @param namespace The namespace to filter for + */ +export const withNamespace = ( + targetNamespace: string +): MonoTypeOperatorFunction> => + filter(({ meta: { namespace } }) => namespace === targetNamespace); diff --git a/src/types/Action.ts b/src/types/Action.ts index 3cb31a9d..82c04f30 100644 --- a/src/types/Action.ts +++ b/src/types/Action.ts @@ -1,7 +1,7 @@ import { VoidPayload } from 'rxbeach/internal'; type Meta = { - qualifiers: string[]; + namespace?: string; }; export type ActionWithoutPayload = {