diff --git a/apps/playground/src/app/flight/flight-edit/flight-edit.store.ts b/apps/playground/src/app/flight/flight-edit/flight-edit.store.ts index ff80696..893cf46 100644 --- a/apps/playground/src/app/flight/flight-edit/flight-edit.store.ts +++ b/apps/playground/src/app/flight/flight-edit/flight-edit.store.ts @@ -1,4 +1,4 @@ -import { withHypermediaResource, withHypermediaAction, withWritableStateCopy, withExperimentalDeepWritableStateCopy, withExperimentalDeepWritableStateDelegate } from "@angular-architects/ngrx-hateoas"; +import { withHypermediaResource, withHypermediaAction, withWritableStateCopy, withExperimentalDeepWritableStateCopy, withExperimentalDeepWritableStateDelegate, withDeepWritableStateProjection } from "@angular-architects/ngrx-hateoas"; import { signalStore, withMethods } from "@ngrx/signals"; import { Aircraft, Flight, initialFlight } from "../flight.entities"; @@ -54,3 +54,12 @@ export const FlightEditViewStore3 = signalStore( withHypermediaAction('updateFlightConnection', store => store.flightEditVm.flight.connection, 'update'), ); +// Option 4: Reading a resource from server, creating a deep writable state projection (suited for reactive forms), +// and sending back parts of the main state to the server +export const FlightEditViewStore4 = signalStore( + { providedIn: 'root' }, + withHypermediaResource('flightEditVm', initialFlightEditVm), + withDeepWritableStateProjection(store => ({ flightFormModel: { from: store.flightEditVm.flight.connection.from, to: store.flightEditVm.flight.connection.to } })), + withHypermediaAction('updateFlightConnection', store => store.flightEditVm.flight.connection, 'update'), +); + diff --git a/doc/docs/guide/05-state_mutation_features/04-withDeepWritableStateProjection.md b/doc/docs/guide/05-state_mutation_features/04-withDeepWritableStateProjection.md new file mode 100644 index 0000000..b50fc85 --- /dev/null +++ b/doc/docs/guide/05-state_mutation_features/04-withDeepWritableStateProjection.md @@ -0,0 +1,110 @@ +--- +sidebar_position: 4 +--- + +# withDeepWritableStateProjection + +`withDeepWritableStateProjection` creates a writable view composed from arbitrary parts of the store state. Every property returned by the selector is a deep writable signal. Nested object nodes are signals as well, not just containers for their child signals. + +This makes it possible to create a form-shaped view without adding a synchronized copy of the selected state. + +## Usage + +```ts +import { signalStore, withState } from '@ngrx/signals'; +import { withDeepWritableStateProjection } from '@angular-architects/ngrx-hateoas'; + +const FlightStore = signalStore( + withState({ + search: { + from: null as string | null, + to: null as string | null, + }, + settings: { + travelClass: 'economy', + language: 'en', + }, + }), + withDeepWritableStateProjection(store => ({ + searchForm: { + route: { + from: store.search.from, + to: store.search.to, + }, + travelClass: store.settings.travelClass, + }, + })), +); +``` + +The projection and all of its nodes can be read as signals: + +```ts +store.searchForm(); +// { +// route: { from: null, to: null }, +// travelClass: 'economy' +// } + +store.searchForm.route(); +// { from: null, to: null } + +store.searchForm.route.from(); +// null +``` + +Every node can also be written: + +```ts +store.searchForm.route.from.set('Graz'); + +store.searchForm.route.set({ + from: 'Graz', + to: 'Hamburg', +}); + +store.searchForm.update(form => ({ + ...form, + travelClass: 'business', +})); +``` + +Writing a structure node distributes its value to all selected state properties below that node. Writes spanning multiple state roots are applied with a single `patchState` operation. State properties that are not part of the projection remain unchanged. + +Changes made to the original state are immediately reflected by the projection. The projection therefore does not introduce an independent state copy and does not need explicit synchronization or reset behavior. + +## API + +```ts +function withDeepWritableStateProjection< + Input extends SignalStoreFeatureResult, + Selection extends DeepWritableStateProjectionSelection, +>( + stateMapFn: (store: MappedStoreState) => Selection, +): SignalStoreFeature +``` + +The selector returns an object whose leaves are state signals. Objects may be nested to create the desired projection shape: + +```ts +type DeepWritableStateProjectionSelection = { + [key: string]: Signal | DeepWritableStateProjectionSelection; +}; +``` + +For every top-level property, the feature adds a `DeepWritableSignal` containing the recursively unwrapped values: + +```ts +type ProjectedValue = + Node extends Signal + ? Value + : Node extends DeepWritableStateProjectionSelection + ? { [Key in keyof Node]: ProjectedValue } + : never; + +type DeepWritableStateProjection = { + [Key in keyof Selection]: DeepWritableSignal>; +}; +``` + +The generated writable signals support `set`, `update`, and `asReadonly` at the projection root, at every structure node, and at every selected leaf. diff --git a/doc/sidebars.ts b/doc/sidebars.ts index 312315b..1b6dd91 100644 --- a/doc/sidebars.ts +++ b/doc/sidebars.ts @@ -43,7 +43,8 @@ const sidebars: SidebarsConfig = { items: [ 'guide/state_mutation_features/withWritableStateCopy', 'guide/state_mutation_features/withDeepWritableStateCopy', - 'guide/state_mutation_features/withDeepWritableStateDelegate' + 'guide/state_mutation_features/withDeepWritableStateDelegate', + 'guide/state_mutation_features/withDeepWritableStateProjection' ], }, { diff --git a/libs/ngrx-hateoas/package.json b/libs/ngrx-hateoas/package.json index f16bedd..af239f3 100644 --- a/libs/ngrx-hateoas/package.json +++ b/libs/ngrx-hateoas/package.json @@ -1,6 +1,6 @@ { "name": "@angular-architects/ngrx-hateoas", - "version": "21.1.0", + "version": "22.0.0-beta.0", "peerDependencies": { "@angular/common": "^21.0.0", "@angular/core": "^21.0.0", diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.spec.ts b/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.spec.ts new file mode 100644 index 0000000..1c15fc4 --- /dev/null +++ b/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.spec.ts @@ -0,0 +1,307 @@ +import { + Injector, + isSignal, + provideZonelessChangeDetection, + runInInjectionContext, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { patchState, signalMethod, signalStore, withMethods, withState } from '@ngrx/signals'; +import { firstValueFrom, timer } from 'rxjs'; +import { withDeepWritableStateProjection } from './with-deep-writable-state-projection'; + +type TestState = { + person: { + firstName: string; + lastName: string; + address: { + city: string; + zipCode: string; + }; + }; + preferences: { + theme: 'light' | 'dark'; + language: string; + }; + counter: number; + untouched: string; +}; + +const initialState: TestState = { + person: { + firstName: 'Ada', + lastName: 'Lovelace', + address: { + city: 'London', + zipCode: 'SW1A', + }, + }, + preferences: { + theme: 'light', + language: 'en', + }, + counter: 1, + untouched: 'keep me', +}; + +const TestStore = signalStore( + withState(initialState), + withDeepWritableStateProjection(store => ({ + editor: { + identity: { + givenName: store.person.firstName, + familyName: store.person.lastName, + }, + location: { + city: store.person.address.city, + }, + appearance: { + theme: store.preferences.theme, + }, + }, + projectedCounter: store.counter, + projectedAddress: store.person.address, + })), + withMethods(store => ({ + updateOriginalState(state: Partial) { + patchState(store, state); + }, + })), +); + +describe('withDeepWritableStateProjection', () => { + let store: InstanceType; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection(), TestStore], + }); + store = TestBed.inject(TestStore); + }); + + it('creates a deep writable signal for every projected structure node and leaf', () => { + expect(isSignal(store.editor)).toBeTrue(); + expect(store.editor.set).toBeDefined(); + expect(store.editor.update).toBeDefined(); + expect(store.editor.asReadonly).toBeDefined(); + + expect(isSignal(store.editor.identity)).toBeTrue(); + expect(store.editor.identity.set).toBeDefined(); + expect(store.editor.identity.update).toBeDefined(); + expect(isSignal(store.editor.identity.givenName)).toBeTrue(); + expect(store.editor.identity.givenName.set).toBeDefined(); + expect(store.editor.identity.givenName.update).toBeDefined(); + + expect(isSignal(store.editor.location)).toBeTrue(); + expect(isSignal(store.editor.location.city)).toBeTrue(); + expect(isSignal(store.editor.appearance)).toBeTrue(); + expect(isSignal(store.editor.appearance.theme)).toBeTrue(); + + expect(isSignal(store.projectedCounter)).toBeTrue(); + expect(store.projectedCounter.set).toBeDefined(); + expect(store.projectedCounter.update).toBeDefined(); + expect(isSignal(store.projectedAddress)).toBeTrue(); + expect(isSignal(store.projectedAddress.city)).toBeTrue(); + expect(store.projectedAddress.city.set).toBeDefined(); + }); + + it('reads a composed value from different parts of the state', () => { + expect(store.editor()).toEqual({ + identity: { + givenName: 'Ada', + familyName: 'Lovelace', + }, + location: { + city: 'London', + }, + appearance: { + theme: 'light', + }, + }); + + expect(store.editor.identity()).toEqual({ + givenName: 'Ada', + familyName: 'Lovelace', + }); + expect(store.editor.identity.givenName()).toBe('Ada'); + expect(store.editor.location.city()).toBe('London'); + expect(store.editor.appearance.theme()).toBe('light'); + expect(store.projectedCounter()).toBe(1); + expect(store.projectedAddress()).toEqual({ city: 'London', zipCode: 'SW1A' }); + }); + + it('sets a whole projection across multiple state roots and preserves unprojected state', () => { + store.editor.set({ + identity: { + givenName: 'Grace', + familyName: 'Hopper', + }, + location: { + city: 'New York', + }, + appearance: { + theme: 'dark', + }, + }); + + expect(store.person()).toEqual({ + firstName: 'Grace', + lastName: 'Hopper', + address: { + city: 'New York', + zipCode: 'SW1A', + }, + }); + expect(store.preferences()).toEqual({ + theme: 'dark', + language: 'en', + }); + expect(store.counter()).toBe(1); + expect(store.untouched()).toBe('keep me'); + expect(store.editor()).toEqual({ + identity: { + givenName: 'Grace', + familyName: 'Hopper', + }, + location: { + city: 'New York', + }, + appearance: { + theme: 'dark', + }, + }); + }); + + it('sets a projected structure node without changing its siblings', () => { + store.editor.identity.set({ + givenName: 'Katherine', + familyName: 'Johnson', + }); + + expect(store.person.firstName()).toBe('Katherine'); + expect(store.person.lastName()).toBe('Johnson'); + expect(store.person.address()).toEqual({ city: 'London', zipCode: 'SW1A' }); + expect(store.preferences.theme()).toBe('light'); + expect(store.editor.location.city()).toBe('London'); + }); + + it('sets individual projected leaves in deeply nested and top-level projections', () => { + store.editor.location.city.set('Paris'); + store.editor.appearance.theme.set('dark'); + store.projectedCounter.set(10); + + expect(store.person.address.city()).toBe('Paris'); + expect(store.person.address.zipCode()).toBe('SW1A'); + expect(store.preferences.theme()).toBe('dark'); + expect(store.preferences.language()).toBe('en'); + expect(store.counter()).toBe(10); + }); + + it('deeply sets and updates a directly selected object signal', () => { + store.projectedAddress.set({ + city: 'Berlin', + zipCode: '10115', + }); + store.projectedAddress.city.update(city => city.toUpperCase()); + + expect(store.projectedAddress()).toEqual({ + city: 'BERLIN', + zipCode: '10115', + }); + expect(store.person.address()).toEqual({ + city: 'BERLIN', + zipCode: '10115', + }); + expect(store.person.firstName()).toBe('Ada'); + expect(store.person.lastName()).toBe('Lovelace'); + }); + + it('updates structure nodes and leaves using their current projected value', () => { + store.editor.identity.update(identity => ({ + givenName: identity.givenName.toUpperCase(), + familyName: identity.familyName.toUpperCase(), + })); + store.projectedCounter.update(counter => counter + 4); + + expect(store.editor.identity()).toEqual({ + givenName: 'ADA', + familyName: 'LOVELACE', + }); + expect(store.person.firstName()).toBe('ADA'); + expect(store.person.lastName()).toBe('LOVELACE'); + expect(store.counter()).toBe(5); + }); + + it('reacts to changes made directly to the original state', () => { + store.updateOriginalState({ + person: { + firstName: 'Dorothy', + lastName: 'Vaughan', + address: { + city: 'Hampton', + zipCode: '23666', + }, + }, + preferences: { + theme: 'dark', + language: 'en', + }, + counter: 7, + }); + + expect(store.editor()).toEqual({ + identity: { + givenName: 'Dorothy', + familyName: 'Vaughan', + }, + location: { + city: 'Hampton', + }, + appearance: { + theme: 'dark', + }, + }); + expect(store.projectedCounter()).toBe(7); + }); + + it('keeps fine-grained reactivity when one projected leaf changes', async () => { + const injector = TestBed.inject(Injector); + let editorNotifications = 0; + let identityNotifications = 0; + let givenNameNotifications = 0; + let familyNameNotifications = 0; + let locationNotifications = 0; + + runInInjectionContext(injector, () => { + signalMethod(() => editorNotifications++)(store.editor); + signalMethod(() => identityNotifications++)(store.editor.identity); + signalMethod(() => givenNameNotifications++)(store.editor.identity.givenName); + signalMethod(() => familyNameNotifications++)(store.editor.identity.familyName); + signalMethod(() => locationNotifications++)(store.editor.location); + }); + + await firstValueFrom(timer(0)); + editorNotifications = 0; + identityNotifications = 0; + givenNameNotifications = 0; + familyNameNotifications = 0; + locationNotifications = 0; + + store.editor.identity.givenName.set('Augusta'); + await firstValueFrom(timer(0)); + + expect(editorNotifications).toBe(1); + expect(identityNotifications).toBe(1); + expect(givenNameNotifications).toBe(1); + expect(familyNameNotifications).toBe(0); + expect(locationNotifications).toBe(0); + }); + + it('exposes a readonly view without writable operations', () => { + const readonlyEditor = store.editor.asReadonly(); + + expect(isSignal(readonlyEditor)).toBeTrue(); + expect(readonlyEditor()).toEqual(store.editor()); + expect((readonlyEditor as unknown as { set?: unknown }).set).toBeUndefined(); + expect((readonlyEditor as unknown as { update?: unknown }).update).toBeUndefined(); + }); +}); diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.ts b/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.ts new file mode 100644 index 0000000..47dc668 --- /dev/null +++ b/libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-projection.ts @@ -0,0 +1,222 @@ +import { computed, isSignal, Signal, WritableSignal } from '@angular/core'; +import { + patchState, + signalStoreFeature, + SignalStoreFeature, + SignalStoreFeatureResult, + StateSignals, + withProps, + WritableStateSource, +} from '@ngrx/signals'; + +export type StateProjectionSelection = { + [key: string]: Signal | StateProjectionSelection; +}; + +type TypeFromSelection = Node extends Signal + ? Value + : Node extends StateProjectionSelection + ? { [Key in keyof Node]: TypeFromSelection } + : never; + +export type DeepWritableStateSignal = WritableSignal & + (T extends Record ? Readonly<{ [K in keyof T]: DeepWritableStateSignal }> : unknown); + +export type DeepWritableStateProjection = { + [Key in keyof Selection]: DeepWritableStateSignal>; +}; + +type MappedStoreState = { + [Key in keyof StateSignals]: StateSignals[Key] extends Signal ? DeepWritableStateSignal : never; +}; + +type SelectStateFn = (store: MappedStoreState) => Selection; + +type StateRecord = Record; + +type StateWrite = { + path: PropertyKey[]; + value: unknown; +}; + +const statePath = Symbol('deepWritableStateProjectionPath'); + +type StateProjectionSignal = DeepWritableStateSignal & { + [statePath]: PropertyKey[]; +}; + +function isStateRecord(value: unknown): value is StateRecord { + return value !== null && typeof value === 'object'; +} + +function setValueAtPath(currentValue: unknown, path: readonly PropertyKey[], value: unknown): unknown { + if (path.length === 0) { + return value; + } + + const [key, ...remainingPath] = path; + const currentRecord = isStateRecord(currentValue) ? currentValue : {}; + + return { + ...currentRecord, + [key]: setValueAtPath(currentRecord[key], remainingPath, value), + }; +} + +function createStateProjectionSignal( + source: Signal, + path: PropertyKey[], + commit: (writes: StateWrite[]) => void, +): StateProjectionSignal { + const children = new Map>(); + + return new Proxy(source, { + get(target, property, receiver) { + if (property === statePath) { + return path; + } + + if (property === 'set') { + return (value: T) => commit([{ path, value }]); + } + + if (property === 'update') { + return (updateFn: (value: T) => T) => commit([{ path, value: updateFn(target()) }]); + } + + if (property === 'asReadonly') { + return () => target; + } + + if (typeof property !== 'symbol') { + const currentValue = target(); + if (isStateRecord(currentValue) && property in currentValue) { + let child = children.get(property); + if (!child) { + child = createStateProjectionSignal( + computed(() => (target() as StateRecord)[property]), + [...path, property], + commit, + ); + children.set(property, child); + } + return child; + } + } + + return Reflect.get(target, property, receiver); + }, + }) as StateProjectionSignal; +} + +function collectWrites( + selection: Signal | StateProjectionSelection, + value: unknown, + writes: StateWrite[], +): void { + if (isSignal(selection)) { + writes.push({ + path: (selection as StateProjectionSignal)[statePath], + value, + }); + return; + } + + const valueRecord = isStateRecord(value) ? value : {}; + for (const key of Object.keys(selection)) { + collectWrites(selection[key], valueRecord[key], writes); + } +} + +function createCompositeProjectionSignal( + selection: Selection, + commit: (writes: StateWrite[]) => void, +): DeepWritableStateSignal> { + const children = Object.fromEntries( + Object.entries(selection).map(([key, child]) => [ + key, + isSignal(child) ? child : createCompositeProjectionSignal(child, commit), + ]), + ) as Record>; + + const source = computed(() => + Object.fromEntries(Object.entries(children).map(([key, child]) => [key, child()])), + ) as Signal>; + + const set = (value: TypeFromSelection) => { + const writes: StateWrite[] = []; + collectWrites(selection, value, writes); + commit(writes); + }; + + return new Proxy(source, { + get(target, property, receiver) { + if (property === 'set') { + return set; + } + + if (property === 'update') { + return (updateFn: (value: TypeFromSelection) => TypeFromSelection) => + set(updateFn(target())); + } + + if (property === 'asReadonly') { + return () => target; + } + + if (typeof property === 'string' && property in children) { + return children[property]; + } + + return Reflect.get(target, property, receiver); + }, + }) as DeepWritableStateSignal>; +} + +function createProjection( + selection: Selection, + commit: (writes: StateWrite[]) => void, +): DeepWritableStateProjection { + return Object.fromEntries( + Object.entries(selection).map(([key, node]) => [ + key, + isSignal(node) ? node : createCompositeProjectionSignal(node, commit), + ]), + ) as DeepWritableStateProjection; +} + +export function withDeepWritableStateProjection( + stateMapFn: SelectStateFn, +): SignalStoreFeature< + Input, + Input & { + props: DeepWritableStateProjection; + } +>; +export function withDeepWritableStateProjection( + stateMapFn: SelectStateFn +) { + return signalStoreFeature( + withProps((store: WritableStateSource & Record>) => { + const commit = (writes: StateWrite[]) => { + patchState(store, state => { + let updatedState: unknown = state; + for (const write of writes) { + updatedState = setValueAtPath(updatedState, write.path, write.value); + } + return updatedState as object; + }); + }; + + const mappedStoreState: Record> = {}; + for (const key in store) { + if (isSignal(store[key])) { + mappedStoreState[key] = createStateProjectionSignal(computed(() => store[key]()), [key], commit); + } + } + + const selection = stateMapFn(mappedStoreState as MappedStoreState); + return createProjection(selection, commit); + }), + ); +} diff --git a/libs/ngrx-hateoas/src/public-api.ts b/libs/ngrx-hateoas/src/public-api.ts index e103332..daedc4a 100644 --- a/libs/ngrx-hateoas/src/public-api.ts +++ b/libs/ngrx-hateoas/src/public-api.ts @@ -18,5 +18,6 @@ export * from './lib/store-features/with-hypermedia-collection-action'; export * from './lib/store-features/with-writable-state-copy'; export * from './lib/store-features/with-deep-writable-state-copy'; export * from './lib/store-features/with-deep-writable-state-delegate'; +export * from './lib/store-features/with-deep-writable-state-projection'; -export { type Patchable, type DeepPatchableSignal } from './lib/util/deep-patchable-signal'; \ No newline at end of file +export { type Patchable, type DeepPatchableSignal } from './lib/util/deep-patchable-signal';