diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index 580cbf4e..9a4d65f9 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -26,6 +26,7 @@ withImmutableState withFeatureFactory withConditional + withConnect diff --git a/apps/demo/src/app/connect/connect.component.ts b/apps/demo/src/app/connect/connect.component.ts new file mode 100644 index 00000000..0082ce68 --- /dev/null +++ b/apps/demo/src/app/connect/connect.component.ts @@ -0,0 +1,41 @@ +import { Component, inject, linkedSignal } from '@angular/core'; +import { signalStore, withState } from '@ngrx/signals'; +import { FormsModule } from '@angular/forms'; +import { withConnect } from '@angular-architects/ngrx-toolkit'; + +const initialState = { user: { name: 'Max' } }; + +const UserStore = signalStore( + { providedIn: 'root' }, + withState(initialState), + withConnect() +); + +@Component({ + template: ` +

+
withConnect
+

+

+ withConnect() adds a method to connect Signals back to your store. + Everytime these Signals change, the store will be updated accordingly. +

+ +

User name in Store: {{ userStore.user.name() }}

+ +

Connected local user name:

+ `, + imports: [FormsModule], +}) +export class ConnectComponent { + protected readonly userStore = inject(UserStore); + protected readonly userName = linkedSignal(() => this.userStore.user.name()); + + constructor() { + this.userStore.connect(() => ({ + user: { + name: this.userName(), + }, + })); + } +} diff --git a/apps/demo/src/app/lazy-routes.ts b/apps/demo/src/app/lazy-routes.ts index d0fc9751..248f0172 100644 --- a/apps/demo/src/app/lazy-routes.ts +++ b/apps/demo/src/app/lazy-routes.ts @@ -61,4 +61,9 @@ export const lazyRoutes: Route[] = [ (m) => m.ConditionalSettingComponent ), }, + { + path: 'connect', + loadComponent: () => + import('./connect/connect.component').then((m) => m.ConnectComponent), + }, ]; diff --git a/docs/docs/with-connect.md b/docs/docs/with-connect.md new file mode 100644 index 00000000..9e1cc5eb --- /dev/null +++ b/docs/docs/with-connect.md @@ -0,0 +1,52 @@ +--- +title: withConnect() +--- + +```typescript +import { withConnect } from '@angular-architects/ngrx-toolkit'; +``` + +`withConnect()` adds a method to connect Signals back to your store. Everytime these Signals change, the store will be updated accordingly. + +Example: + +```ts +const Store = signalStore( + { protectedState: false }, + withState({ + maxWarpFactor: 8, + shipName: 'USS Enterprise', + registration: 'NCC-1701', + poeple: 430, + }), + withConnect() +); +``` + +```ts +@Component({ ... }) +export class MyComponent { + private store = inject(OfferListStore); + + readonly maxWarpFactor = signal(8); + readonly registration = signal('NCC-1701'); + readonly people = signal(430); + + constructor() { + // + // Every change in the local Signals is + // refected in the store + // + this.store.connect(() => ({ + // + // Subset of state in the store + // + maxWarpFactor: this.maxWarpFactor(), + registration: this.registration(), + poeple: this.poeple(), + })); + } + + ... +} +``` diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 11407798..0f7e29b0 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -26,6 +26,7 @@ const sidebars: SidebarsConfig = { 'with-feature-factory', 'with-conditional', 'with-call-state', + 'with-connect', ], reduxConnectorSidebar: [ { diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts index 6bc484fe..0a8ce2ac 100644 --- a/libs/ngrx-toolkit/src/index.ts +++ b/libs/ngrx-toolkit/src/index.ts @@ -41,3 +41,4 @@ export { } from './lib/storage-sync/with-storage-sync'; export { emptyFeature, withConditional } from './lib/with-conditional'; export { withFeatureFactory } from './lib/with-feature-factory'; +export * from './lib/with-connect'; diff --git a/libs/ngrx-toolkit/src/lib/with-connect.spec.ts b/libs/ngrx-toolkit/src/lib/with-connect.spec.ts new file mode 100644 index 00000000..9ad25bac --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-connect.spec.ts @@ -0,0 +1,61 @@ +import { getState, patchState, signalStore, withState } from '@ngrx/signals'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { withConnect } from './with-connect'; + +describe('withConnect', () => { + const setup = () => { + const Store = signalStore( + { protectedState: false }, + withState({ + maxWarpFactor: 8, + shipName: 'USS Enterprise', + registration: 'NCC-1701', + poeple: 430, + }), + withConnect('maxWarpFactor', 'registration', 'poeple') + ); + + const store = TestBed.configureTestingModule({ + providers: [Store], + }).inject(Store); + + return { store }; + }; + + it('should update store after connected signals are changed', fakeAsync(() => { + const { store } = setup(); + + TestBed.runInInjectionContext(() => { + const maxWarpFactor = signal(9); + const registration = signal('NCC-1701-D'); + const poeple = signal(1100); + + store.connect(() => ({ + maxWarpFactor: maxWarpFactor(), + registration: registration(), + poeple: poeple(), + })); + tick(1); + expect(getState(store)).toMatchObject({ + maxWarpFactor: 9, + shipName: 'USS Enterprise', + registration: 'NCC-1701-D', + poeple: 1100, + }); + + maxWarpFactor.set(9.6); + tick(1); + expect(getState(store).maxWarpFactor).toBeCloseTo(9.6); + expect(getState(store).shipName).toEqual('USS Enterprise'); + + patchState(store, { maxWarpFactor: 9.2 }); + tick(1); + expect(getState(store).maxWarpFactor).toBeCloseTo(9.2); + expect(getState(store).shipName).toEqual('USS Enterprise'); + + // It's just a one-way-sync + expect(maxWarpFactor()).toBeCloseTo(9.6); + }); + })); +}); diff --git a/libs/ngrx-toolkit/src/lib/with-connect.ts b/libs/ngrx-toolkit/src/lib/with-connect.ts new file mode 100644 index 00000000..3b109401 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-connect.ts @@ -0,0 +1,55 @@ +import { computed } from '@angular/core'; +import { + EmptyFeatureResult, + patchState, + signalMethod, + SignalStoreFeature, + signalStoreFeature, + SignalStoreFeatureResult, + withMethods, +} from '@ngrx/signals'; + +export type WithConnectResultType< + T extends SignalStoreFeatureResult, + K extends keyof T['state'] +> = EmptyFeatureResult & { + methods: { + connect(state: () => Partial>): void; + }; +}; + +export function withConnect< + T extends SignalStoreFeatureResult, + Keys extends readonly (keyof T['state'])[] +>( + ...keys: Keys +): SignalStoreFeature> { + return signalStoreFeature( + withMethods((store) => { + return { + connect(stateFn: () => Partial>): void { + const stateSignal = computed(stateFn); + + // TypeScript allows additional keys + validateKeys(stateFn, keys); + + signalMethod>>((state) => { + patchState(store, state); + })(stateSignal); + }, + }; + }) + ); +} + +function validateKeys< + T extends SignalStoreFeatureResult, + Keys extends readonly (keyof T['state'])[] +>(stateFn: () => Partial>, keys: Keys) { + const candKeys = Object.keys(stateFn()) as unknown as Keys; + for (const key of candKeys) { + if (!keys.includes(key)) { + throw new Error(`Key ${String(key)} is not provided via withConnect!`); + } + } +}