Skip to content

Commit

Permalink
feat(StoreDevtools): implement actionsBlacklist/Whitelist & predicate
Browse files Browse the repository at this point in the history
  • Loading branch information
Wykks committed Sep 1, 2018
1 parent fdc6e93 commit cfb13b7
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 18 deletions.
10 changes: 9 additions & 1 deletion docs/store-devtools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
118 changes: 118 additions & 0 deletions modules/store-devtools/spec/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,124 @@ 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],
})
);
// 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],
})
);
// 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,
})
);
// 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', () => {
Expand Down
4 changes: 4 additions & 0 deletions modules/store-devtools/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type SerializationOptions = {
immutable?: any;
refs?: Array<any>;
};
export type Predicate = (state: any, action: Action) => boolean;

export class StoreDevtoolsConfig {
maxAge: number | false;
Expand All @@ -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<StoreDevtoolsConfig>(
Expand Down
39 changes: 24 additions & 15 deletions modules/store-devtools/src/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
Expand Down Expand Up @@ -72,17 +73,25 @@ export class StoreDevtools implements Observer<any> {
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);
Expand All @@ -100,7 +109,7 @@ export class StoreDevtools implements Observer<any> {

const liftedState$ = liftedStateSubject.asObservable() as Observable<
LiftedState
>;
>;
const state$ = liftedState$.pipe(map(unliftState));

this.extensionStartSubscription = extensionStartSubscription;
Expand All @@ -118,9 +127,9 @@ export class StoreDevtools implements Observer<any> {
this.dispatcher.next(action);
}

error(error: any) {}
error(error: any) { }

complete() {}
complete() { }

performAction(action: any) {
this.dispatch(new Actions.PerformAction(action, +Date.now()));
Expand Down
17 changes: 16 additions & 1 deletion modules/store-devtools/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import {
sanitizeState,
sanitizeStates,
unliftState,
isActionFiltered,
filterLiftedState,
shouldFilterActions,
} from './utils';
import { UPDATE } from '@ngrx/store';
import { DevtoolsDispatcher } from './devtools-dispatcher';
Expand Down Expand Up @@ -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).
Expand All @@ -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,
Expand All @@ -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,
Expand Down
70 changes: 69 additions & 1 deletion modules/store-devtools/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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('|')))
);
}

0 comments on commit cfb13b7

Please sign in to comment.