From 7ee46d2f60cf545e5b821f0848cd3c0aac970f70 Mon Sep 17 00:00:00 2001 From: Guillaume de Jabrun Date: Fri, 14 Sep 2018 05:07:08 +0200 Subject: [PATCH] feat(StoreDevtools): implement actionsBlacklist/Whitelist & predicate (#970) Closes #938 --- docs/store-devtools/README.md | 10 +- modules/store-devtools/spec/extension.spec.ts | 121 ++++++++++++++++++ modules/store-devtools/src/config.ts | 4 + modules/store-devtools/src/devtools.ts | 39 +++--- modules/store-devtools/src/extension.ts | 17 ++- modules/store-devtools/src/utils.ts | 70 +++++++++- 6 files changed, 243 insertions(+), 18 deletions(-) diff --git a/docs/store-devtools/README.md b/docs/store-devtools/README.md index e38c5e18b6..ec10cb80e0 100644 --- a/docs/store-devtools/README.md +++ b/docs/store-devtools/README.md @@ -37,7 +37,7 @@ import { environment } from '../environments/environment'; // Angular CLI enviro export class AppModule {} ``` -***NOTE:*** Once some component injects the `Store` service, Devtools will be enabled. +**_NOTE:_** Once some component injects the `Store` service, Devtools will be enabled. ### Instrumentation options @@ -70,3 +70,11 @@ function = takes `state` object and index as arguments, and should return `state #### `serialize` false | configuration object - Handle the way you want to serialize your state, [more information here](https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md#serialize). + +#### `actionsBlacklist / actionsWhitelist` + +array of strings as regex - actions types to be hidden / shown in the monitors (while passed to the reducers), [more information here](https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md#actionsblacklist--actionswhitelist). + +#### `predicate` + +function - called for every action before sending, takes state and action object, and returns true in case it allows sending the current data to the monitor, [more information here](https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md#predicate). diff --git a/modules/store-devtools/spec/extension.spec.ts b/modules/store-devtools/spec/extension.spec.ts index ba607fb74f..e0e4408d92 100644 --- a/modules/store-devtools/spec/extension.spec.ts +++ b/modules/store-devtools/spec/extension.spec.ts @@ -373,6 +373,127 @@ describe('DevtoolsExtension', () => { }); }); }); + + describe('with Action and actionsBlacklist', () => { + const NORMAL_ACTION = 'NORMAL_ACTION'; + const BLACKLISTED_ACTION = 'BLACKLISTED_ACTION'; + + beforeEach(() => { + devtoolsExtension = new DevtoolsExtension( + reduxDevtoolsExtension, + createConfig({ + actionsBlacklist: [BLACKLISTED_ACTION], + }), + null + ); + // Subscription needed or else extension connection will not be established. + devtoolsExtension.actions$.subscribe(() => null); + }); + + it('should ignore blacklisted action', () => { + const options = createOptions(); + const state = createState(); + + devtoolsExtension.notify( + new PerformAction({ type: NORMAL_ACTION }, 1234567), + state + ); + devtoolsExtension.notify( + new PerformAction({ type: NORMAL_ACTION }, 1234567), + state + ); + devtoolsExtension.notify( + new PerformAction({ type: BLACKLISTED_ACTION }, 1234567), + state + ); + expect(extensionConnection.send).toHaveBeenCalledTimes(2); + }); + }); + + describe('with Action and actionsWhitelist', () => { + const NORMAL_ACTION = 'NORMAL_ACTION'; + const WHITELISTED_ACTION = 'WHITELISTED_ACTION'; + + beforeEach(() => { + devtoolsExtension = new DevtoolsExtension( + reduxDevtoolsExtension, + createConfig({ + actionsWhitelist: [WHITELISTED_ACTION], + }), + null + ); + // Subscription needed or else extension connection will not be established. + devtoolsExtension.actions$.subscribe(() => null); + }); + + it('should only keep whitelisted action', () => { + const options = createOptions(); + const state = createState(); + + devtoolsExtension.notify( + new PerformAction({ type: NORMAL_ACTION }, 1234567), + state + ); + devtoolsExtension.notify( + new PerformAction({ type: NORMAL_ACTION }, 1234567), + state + ); + devtoolsExtension.notify( + new PerformAction({ type: WHITELISTED_ACTION }, 1234567), + state + ); + expect(extensionConnection.send).toHaveBeenCalledTimes(1); + }); + }); + + describe('with Action and predicate', () => { + const NORMAL_ACTION = 'NORMAL_ACTION'; + const RANDOM_ACTION = 'RANDOM_ACTION'; + + const predicate = jasmine + .createSpy('predicate', (state: any, action: Action) => { + if (action.type === RANDOM_ACTION) { + return false; + } + return true; + }) + .and.callThrough(); + + beforeEach(() => { + devtoolsExtension = new DevtoolsExtension( + reduxDevtoolsExtension, + createConfig({ + predicate, + }), + null + ); + // Subscription needed or else extension connection will not be established. + devtoolsExtension.actions$.subscribe(() => null); + }); + + it('should ignore action according to predicate', () => { + const options = createOptions(); + const state = createState(); + + devtoolsExtension.notify( + new PerformAction({ type: NORMAL_ACTION }, 1234567), + state + ); + expect(predicate).toHaveBeenCalledWith(unliftState(state), { + type: NORMAL_ACTION, + }); + devtoolsExtension.notify( + new PerformAction({ type: NORMAL_ACTION }, 1234567), + state + ); + devtoolsExtension.notify( + new PerformAction({ type: RANDOM_ACTION }, 1234567), + state + ); + expect(predicate).toHaveBeenCalledTimes(3); + expect(extensionConnection.send).toHaveBeenCalledTimes(2); + }); + }); }); describe('with locked recording', () => { diff --git a/modules/store-devtools/src/config.ts b/modules/store-devtools/src/config.ts index aceeeb753a..47c13b2d9c 100644 --- a/modules/store-devtools/src/config.ts +++ b/modules/store-devtools/src/config.ts @@ -10,6 +10,7 @@ export type SerializationOptions = { immutable?: any; refs?: Array; }; +export type Predicate = (state: any, action: Action) => boolean; export class StoreDevtoolsConfig { maxAge: number | false; @@ -20,6 +21,9 @@ export class StoreDevtoolsConfig { serialize?: boolean | SerializationOptions; logOnly?: boolean; features?: any; + actionsBlacklist?: string[]; + actionsWhitelist?: string[]; + predicate?: Predicate; } export const STORE_DEVTOOLS_CONFIG = new InjectionToken( diff --git a/modules/store-devtools/src/devtools.ts b/modules/store-devtools/src/devtools.ts index d3639b4a13..4fc485b374 100644 --- a/modules/store-devtools/src/devtools.ts +++ b/modules/store-devtools/src/devtools.ts @@ -21,8 +21,9 @@ import * as Actions from './actions'; import { STORE_DEVTOOLS_CONFIG, StoreDevtoolsConfig } from './config'; import { DevtoolsExtension } from './extension'; import { LiftedState, liftInitialState, liftReducerWith } from './reducer'; -import { liftAction, unliftState } from './utils'; +import { liftAction, unliftState, shouldFilterActions, filterLiftedState } from './utils'; import { DevtoolsDispatcher } from './devtools-dispatcher'; +import { PERFORM_ACTION } from './actions'; @Injectable() export class StoreDevtools implements Observer { @@ -72,17 +73,25 @@ export class StoreDevtools implements Observer { state: LiftedState; action: any; } - >( - ({ state: liftedState }, [action, reducer]) => { - const reducedLiftedState = reducer(liftedState, action); - - // // Extension should be sent the sanitized lifted state - extension.notify(action, reducedLiftedState); - - return { state: reducedLiftedState, action }; - }, - { state: liftedInitialState, action: null as any } - ) + >( + ({ state: liftedState }, [action, reducer]) => { + let reducedLiftedState = reducer(liftedState, action); + // On full state update + // If we have actions filters, we must filter completly our lifted state to be sync with the extension + if (action.type !== PERFORM_ACTION && shouldFilterActions(config)) { + reducedLiftedState = filterLiftedState( + reducedLiftedState, + config.predicate, + config.actionsWhitelist, + config.actionsBlacklist + ); + } + // Extension should be sent the sanitized lifted state + extension.notify(action, reducedLiftedState); + return { state: reducedLiftedState, action }; + }, + { state: liftedInitialState, action: null as any } + ) ) .subscribe(({ state, action }) => { liftedStateSubject.next(state); @@ -100,7 +109,7 @@ export class StoreDevtools implements Observer { const liftedState$ = liftedStateSubject.asObservable() as Observable< LiftedState - >; + >; const state$ = liftedState$.pipe(map(unliftState)); this.extensionStartSubscription = extensionStartSubscription; @@ -118,9 +127,9 @@ export class StoreDevtools implements Observer { this.dispatcher.next(action); } - error(error: any) {} + error(error: any) { } - complete() {} + complete() { } performAction(action: any) { this.dispatch(new Actions.PerformAction(action, +Date.now())); diff --git a/modules/store-devtools/src/extension.ts b/modules/store-devtools/src/extension.ts index f829d3ac93..a3337ddfc4 100644 --- a/modules/store-devtools/src/extension.ts +++ b/modules/store-devtools/src/extension.ts @@ -27,6 +27,9 @@ import { sanitizeState, sanitizeStates, unliftState, + isActionFiltered, + filterLiftedState, + shouldFilterActions, } from './utils'; import { UPDATE } from '@ngrx/store'; import { DevtoolsDispatcher } from './devtools-dispatcher'; @@ -85,7 +88,6 @@ export class DevtoolsExtension { if (!this.devtoolsExtension) { return; } - // Check to see if the action requires a full update of the liftedState. // If it is a simple action generated by the user's app and the recording // is not locked/paused, only send the action and the current state (fast). @@ -105,6 +107,18 @@ export class DevtoolsExtension { } const currentState = unliftState(state); + if ( + shouldFilterActions(this.config) && + isActionFiltered( + currentState, + action, + this.config.predicate, + this.config.actionsWhitelist, + this.config.actionsBlacklist + ) + ) { + return; + } const sanitizedState = this.config.stateSanitizer ? sanitizeState( this.config.stateSanitizer, @@ -124,6 +138,7 @@ export class DevtoolsExtension { // Requires full state update const sanitizedLiftedState = { ...state, + stagedActionIds: state.stagedActionIds, actionsById: this.config.actionSanitizer ? sanitizeActions(this.config.actionSanitizer, state.actionsById) : state.actionsById, diff --git a/modules/store-devtools/src/utils.ts b/modules/store-devtools/src/utils.ts index 3adf008dfa..23255a1650 100644 --- a/modules/store-devtools/src/utils.ts +++ b/modules/store-devtools/src/utils.ts @@ -2,7 +2,12 @@ import { Action } from '@ngrx/store'; import { Observable } from 'rxjs'; import * as Actions from './actions'; -import { ActionSanitizer, StateSanitizer } from './config'; +import { + ActionSanitizer, + StateSanitizer, + Predicate, + StoreDevtoolsConfig, +} from './config'; import { ComputedState, LiftedAction, @@ -93,3 +98,66 @@ export function sanitizeState( ) { return stateSanitizer(state, stateIdx); } + +/** + * Read the config and tell if actions should be filtered + */ +export function shouldFilterActions(config: StoreDevtoolsConfig) { + return config.predicate || config.actionsWhitelist || config.actionsBlacklist; +} + +/** + * Return a full filtered lifted state + */ +export function filterLiftedState( + liftedState: LiftedState, + predicate?: Predicate, + whitelist?: string[], + blacklist?: string[] +): LiftedState { + const filteredStagedActionIds: number[] = []; + const filteredActionsById: LiftedActions = {}; + const filteredComputedStates: ComputedState[] = []; + liftedState.stagedActionIds.forEach((id, idx) => { + const liftedAction = liftedState.actionsById[id]; + if (!liftedAction) return; + if ( + idx && + isActionFiltered( + liftedState.computedStates[idx], + liftedAction, + predicate, + whitelist, + blacklist + ) + ) { + return; + } + filteredActionsById[id] = liftedAction; + filteredStagedActionIds.push(id); + filteredComputedStates.push(liftedState.computedStates[idx]); + }); + return { + ...liftedState, + stagedActionIds: filteredStagedActionIds, + actionsById: filteredActionsById, + computedStates: filteredComputedStates, + }; +} + +/** + * Return true is the action should be ignored + */ +export function isActionFiltered( + state: any, + action: LiftedAction, + predicate?: Predicate, + whitelist?: string[], + blacklist?: string[] +) { + return ( + (predicate && !predicate(state, action.action)) || + (whitelist && !action.action.type.match(whitelist.join('|'))) || + (blacklist && action.action.type.match(blacklist.join('|'))) + ); +}