From b394124991fc06a72d9a87491cc3140e19e6d40a Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 7 Jan 2025 08:47:41 +0100 Subject: [PATCH 1/3] feat(devtools): add `withMapper` extension --- docs/docs/with-devtools.md | 10 ++- libs/ngrx-toolkit/src/index.ts | 1 + .../src/lib/devtools/devtools-feature.ts | 18 +++-- .../internal/devtools-syncer.service.ts | 13 ++-- .../lib/devtools/tests/with-mapper.spec.ts | 69 +++++++++++++++++++ .../src/lib/devtools/with-devtools.ts | 9 +-- .../devtools/with-disabled-name-indicies.ts | 2 +- .../src/lib/devtools/with-mapper.ts | 15 ++++ 8 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 libs/ngrx-toolkit/src/lib/devtools/tests/with-mapper.spec.ts create mode 100644 libs/ngrx-toolkit/src/lib/devtools/with-mapper.ts diff --git a/docs/docs/with-devtools.md b/docs/docs/with-devtools.md index 18e3009..166a3f4 100644 --- a/docs/docs/with-devtools.md +++ b/docs/docs/with-devtools.md @@ -75,6 +75,14 @@ You activate per store: const Store = signalStore({ providedIn: 'root' }, withDevtools('flights', withDisabledNameIndices()), withState({ airline: 'Lufthansa' })); ``` +## `withMapper()` + +`withMapper` allows you to define a function that maps the state before it is sent to the Devtools. + +Sometimes, it is necessary to map the state before it is sent to the Devtools. For example, you might want to exclude some properties, like passwords or other sensitive data. + +````typescript + ## Disabling Devtools in production `withDevtools()` is by default enabled in production mode, if you want to tree-shake it from the application bundle you need to abstract it in your environment file. @@ -89,7 +97,7 @@ import { withDevtools } from '@angular-architects/ngrx-toolkit'; export const environment = { storeWithDevTools: withDevtools, }; -``` +```` environments/environment.prod.ts diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts index 34c2802..7230316 100644 --- a/libs/ngrx-toolkit/src/index.ts +++ b/libs/ngrx-toolkit/src/index.ts @@ -1,6 +1,7 @@ export { withDevToolsStub } from './lib/devtools/with-dev-tools-stub'; export { withDevtools } from './lib/devtools/with-devtools'; export { withDisabledNameIndices } from './lib/devtools/with-disabled-name-indicies'; +export { withMapper } from './lib/devtools/with-mapper'; export { patchState, updateState } from './lib/devtools/update-state'; export { renameDevtoolsName } from './lib/devtools/rename-devtools-name'; diff --git a/libs/ngrx-toolkit/src/lib/devtools/devtools-feature.ts b/libs/ngrx-toolkit/src/lib/devtools/devtools-feature.ts index 10d987b..269e462 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/devtools-feature.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/devtools-feature.ts @@ -1,5 +1,12 @@ export const DEVTOOLS_FEATURE = Symbol('DEVTOOLS_FEATURE'); +export type Mapper = (state: object) => object; + +export type DevtoolsOptions = { + indexNames: boolean; // defines if names should be indexed. + map: Mapper; // defines a mapper for the state. +}; + /** * A DevtoolsFeature adds or modifies the behavior of the * devtools extension. @@ -7,14 +14,15 @@ export const DEVTOOLS_FEATURE = Symbol('DEVTOOLS_FEATURE'); * We use them (function calls) instead of a config object, * because of tree-shaking. */ -export interface DevtoolsFeature { +export type DevtoolsFeature = { [DEVTOOLS_FEATURE]: true; - indexNames: boolean | undefined; // defines if names should be indexed. -} +} & Partial; -export function createDevtoolsFeature(indexNames = true): DevtoolsFeature { +export function createDevtoolsFeature( + options: Partial +): DevtoolsFeature { return { [DEVTOOLS_FEATURE]: true, - indexNames, + ...options, }; } diff --git a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts index db8c202..ddb8740 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts @@ -8,8 +8,9 @@ import { } from '@angular/core'; import { currentActionNames } from './currrent-action-names'; import { isPlatformBrowser } from '@angular/common'; -import { Connection, DevtoolsOptions } from '../with-devtools'; +import { Connection } from '../with-devtools'; import { getState, StateSource } from '@ngrx/signals'; +import { DevtoolsOptions } from '../devtools-feature'; const dummyConnection: Connection = { send: () => void true, @@ -60,8 +61,8 @@ export class DevtoolsSyncer implements OnDestroy { const stores = this.#stores(); const rootState: Record = {}; for (const name in stores) { - const { store } = stores[name]; - rootState[name] = getState(store); + const { store, options } = stores[name]; + rootState[name] = options.map(getState(store)); } const names = Array.from(currentActionNames); @@ -137,5 +138,9 @@ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true }) type StoreRegistry = Record< string, - { store: StateSource; options: DevtoolsOptions; id: number } + { + store: StateSource; + options: DevtoolsOptions; + id: number; + } >; diff --git a/libs/ngrx-toolkit/src/lib/devtools/tests/with-mapper.spec.ts b/libs/ngrx-toolkit/src/lib/devtools/tests/with-mapper.spec.ts new file mode 100644 index 0000000..2988dab --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/devtools/tests/with-mapper.spec.ts @@ -0,0 +1,69 @@ +import { setupExtensions } from './helpers.spec'; +import { TestBed } from '@angular/core/testing'; +import { signalStore, withState } from '@ngrx/signals'; +import { withMapper } from '../with-mapper'; +import { withDevtools } from '../with-devtools'; + +function domRemover(state: Record) { + return Object.keys(state).reduce((acc, key) => { + const value = state[key]; + + if (value instanceof HTMLElement) { + return acc; + } else { + return { ...acc, [key]: value }; + } + }, {}); +} + +describe('with-mapper', () => { + it('should remove DOM Nodes', () => { + const { sendSpy } = setupExtensions(); + + const Store = signalStore( + { providedIn: 'root' }, + withState({ + name: 'Car', + carElement: document.createElement('div'), + }), + withDevtools('shop', withMapper(domRemover)) + ); + + TestBed.inject(Store); + TestBed.flushEffects(); + expect(sendSpy).toHaveBeenCalledWith( + { type: 'Store Update' }, + { shop: { name: 'Car' } } + ); + }); + + it('should every property ending with *Key', () => { + const { sendSpy } = setupExtensions(); + const Store = signalStore( + { providedIn: 'root' }, + withState({ + name: 'Car', + unlockKey: '1234', + }), + withDevtools( + 'shop', + withMapper((state: Record) => + Object.keys(state).reduce((acc, key) => { + if (key.endsWith('Key')) { + return acc; + } else { + return { ...acc, [key]: state[key] }; + } + }, {}) + ) + ) + ); + + TestBed.inject(Store); + TestBed.flushEffects(); + expect(sendSpy).toHaveBeenCalledWith( + { type: 'Store Update' }, + { shop: { name: 'Car' } } + ); + }); +}); diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts index 0cf1d0c..951cceb 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts @@ -1,7 +1,7 @@ import { signalStoreFeature, withHooks, withMethods } from '@ngrx/signals'; import { inject } from '@angular/core'; import { DevtoolsSyncer } from './internal/devtools-syncer.service'; -import { DevtoolsFeature } from './devtools-feature'; +import { DevtoolsFeature, DevtoolsOptions } from './devtools-feature'; export type Action = { type: string }; export type Connection = { @@ -17,10 +17,6 @@ declare global { } } -export type DevtoolsOptions = { - indexNames: boolean; -}; - export const existingNames = new Map(); export const renameDevtoolsMethodName = '___renameDevtoolsName'; @@ -46,8 +42,9 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { ); } existingNames.set(name, true); - const finalOptions = { + const finalOptions: DevtoolsOptions = { indexNames: !features.some((f) => f.indexNames === false), + map: features.find((f) => f.map)?.map ?? ((state) => state), }; return signalStoreFeature( diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-disabled-name-indicies.ts b/libs/ngrx-toolkit/src/lib/devtools/with-disabled-name-indicies.ts index 9b4c1d9..9b3e04a 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-disabled-name-indicies.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-disabled-name-indicies.ts @@ -26,5 +26,5 @@ import { createDevtoolsFeature } from './devtools-feature'; * */ export function withDisabledNameIndices() { - return createDevtoolsFeature(false); + return createDevtoolsFeature({ indexNames: false }); } diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-mapper.ts b/libs/ngrx-toolkit/src/lib/devtools/with-mapper.ts new file mode 100644 index 0000000..335496f --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/devtools/with-mapper.ts @@ -0,0 +1,15 @@ +import { createDevtoolsFeature, Mapper } from './devtools-feature'; + +/** + * Allows you to define a function to map the state. + * + * It is needed for huge states, that slows down the Devtools and where + * you don't need to see the whole state. + * + * @param map function which maps the state + */ +export function withMapper( + map: (state: State) => Record +) { + return createDevtoolsFeature({ map: map as Mapper }); +} From b8cac0ea1d4faa26f6eb7b4e2bd20d28ef4b558c Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 7 Jan 2025 09:19:22 +0100 Subject: [PATCH 2/3] feat(devtools): add `withMapper` extension to demo app --- .../src/app/devtools/todo-detail.component.ts | 20 +++++++++++++++++-- apps/demo/src/app/devtools/todo-store.ts | 3 +-- apps/demo/src/app/devtools/todo.component.ts | 2 ++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/demo/src/app/devtools/todo-detail.component.ts b/apps/demo/src/app/devtools/todo-detail.component.ts index 91ec342..f550cfd 100644 --- a/apps/demo/src/app/devtools/todo-detail.component.ts +++ b/apps/demo/src/app/devtools/todo-detail.component.ts @@ -5,6 +5,7 @@ import { signalStore, withState } from '@ngrx/signals'; import { renameDevtoolsName, withDevtools, + withMapper, } from '@angular-architects/ngrx-toolkit'; /** @@ -17,8 +18,23 @@ import { * run renameDevtoolsStore() in the effect. */ const TodoDetailStore = signalStore( - withDevtools('todo-detail'), - withState({ id: 1 }) + withDevtools( + 'todo-detail', + withMapper((state: Record) => { + return Object.keys(state).reduce((acc, key) => { + if (key === 'secret') { + return acc; + } + acc[key] = state[key]; + + return acc; + }, {} as Record); + }) + ), + withState({ + id: 1, + secret: 'do not show in DevTools', + }) ); @Component({ diff --git a/apps/demo/src/app/devtools/todo-store.ts b/apps/demo/src/app/devtools/todo-store.ts index dc00caa..5e48a49 100644 --- a/apps/demo/src/app/devtools/todo-store.ts +++ b/apps/demo/src/app/devtools/todo-store.ts @@ -11,7 +11,7 @@ import { updateEntity, withEntities, } from '@ngrx/signals/entities'; -import { updateState, withDevtools } from '@angular-architects/ngrx-toolkit'; +import { updateState } from '@angular-architects/ngrx-toolkit'; import { computed } from '@angular/core'; export interface Todo { @@ -26,7 +26,6 @@ export type AddTodo = Omit; export const TodoStore = signalStore( { providedIn: 'root' }, - withDevtools('todo'), withEntities(), withState({ selectedIds: [] as number[], diff --git a/apps/demo/src/app/devtools/todo.component.ts b/apps/demo/src/app/devtools/todo.component.ts index 8beedb9..89f4388 100644 --- a/apps/demo/src/app/devtools/todo.component.ts +++ b/apps/demo/src/app/devtools/todo.component.ts @@ -5,6 +5,7 @@ import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { SelectionModel } from '@angular/cdk/collections'; import { Todo, TodoStore } from './todo-store'; import { TodoDetailComponent } from './todo-detail.component'; +import { FormsModule } from '@angular/forms'; @Component({ selector: 'demo-todo', @@ -68,6 +69,7 @@ import { TodoDetailComponent } from './todo-detail.component'; MatIconModule, MatTableModule, TodoDetailComponent, + FormsModule, ], }) export class TodoComponent { From 52c5fc2f19ac97c9b869b77aee25d5eb97d94cf4 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 7 Jan 2025 09:25:52 +0100 Subject: [PATCH 3/3] feat(devtools): fix --- apps/demo/src/app/devtools/todo-store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/demo/src/app/devtools/todo-store.ts b/apps/demo/src/app/devtools/todo-store.ts index 5e48a49..825b6bb 100644 --- a/apps/demo/src/app/devtools/todo-store.ts +++ b/apps/demo/src/app/devtools/todo-store.ts @@ -11,7 +11,7 @@ import { updateEntity, withEntities, } from '@ngrx/signals/entities'; -import { updateState } from '@angular-architects/ngrx-toolkit'; +import { updateState, withDevtools } from '@angular-architects/ngrx-toolkit'; import { computed } from '@angular/core'; export interface Todo { @@ -26,6 +26,7 @@ export type AddTodo = Omit; export const TodoStore = signalStore( { providedIn: 'root' }, + withDevtools('todo-store'), withEntities(), withState({ selectedIds: [] as number[],