Skip to content

Commit 2d0a631

Browse files
With writable state features (#54)
1 parent 233727a commit 2d0a631

19 files changed

Lines changed: 945 additions & 132 deletions

.vscode/launch.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
55
"version": "0.2.0",
66
"configurations": [
7+
{
8+
"name": "Attach to Chrome",
9+
"port": 9222,
10+
"request": "attach",
11+
"type": "chrome",
12+
"webRoot": "${workspaceFolder}"
13+
},
714
{
815
"type": "chrome",
916
"request": "launch",

apps/playground/src/app/flight/flight-edit/flight-edit.component.html

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,31 @@
44
</div>
55
</div>
66

7+
<pre>
8+
State
9+
{{ store.flightEditVm.flight.connection() | json }}
10+
WritableState
11+
{{ store.flightConnection() | json }}
12+
Local Flight Connection
13+
{{ localFlightConnection() | json }}
14+
</pre>
15+
716
<div class="row">
817
<div class="col">
918
<app-action-card #connection
1019
[disabled]="!store.updateFlightConnectionState.isAvailable()"
1120
(save)="onUpdateConnection()">
12-
<app-flight-connection-form [model]="flightConnection()" (modelChange)="flightConnection.set($event)" />
21+
<app-flight-connection-form [(model)]="flightConnection" />
1322
</app-action-card>
1423
<app-action-card #times
1524
[disabled]="!store.updateFlightTimesState.isAvailable()"
1625
(save)="onUpdateTimes()">
17-
<app-flight-times-form [model]="flightTimes()" (modelChange)="flightTimes.set($event)" />
26+
<app-flight-times-form [(model)]="flightTimes" />
1827
</app-action-card>
1928
<app-action-card #operator
2029
[disabled]="!store.updateFlightOperatorState.isAvailable()"
2130
(save)="onUpdateOperator()">
22-
<app-flight-operator-form [aircrafts]="viewModel.aircrafts()" [model]="flightOperator()" (modelChange)="flightOperator.set($event)" />
31+
<app-flight-operator-form [aircrafts]="store.flightEditVm.aircrafts()" [(model)]="flightOperator" />
2332
</app-action-card>
2433
@if(flightPrice(); as flightPriceValue) {
2534
<app-action-card #price

apps/playground/src/app/flight/flight-edit/flight-edit.component.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1-
import { Component, inject, viewChild } from '@angular/core';
1+
import { Component, inject, linkedSignal, viewChild } from '@angular/core';
22
import { ActionCardComponent } from '../../shared/ui/action-card/action-card.component';
33
import { FlightConnectionFormComponent } from '../shared/flight-connection-form/flight-connection-form.component';
44
import { FlightOperatorFormComponent } from '../shared/flight-operator-form/flight-operator-form.component';
55
import { FlightPriceFormComponent } from '../shared/flight-price-form/flight-price-form.component';
66
import { FlightTimesFormComponent } from '../shared/flight-times-form/flight-times-form.component';
77
import { FlightEditStore } from './flight-edit.store';
8+
import { JsonPipe } from "@angular/common";
89

910
@Component({
1011
selector: 'app-flight-edit',
11-
imports: [ActionCardComponent, FlightConnectionFormComponent, FlightOperatorFormComponent, FlightTimesFormComponent, FlightPriceFormComponent],
12+
imports: [ActionCardComponent, FlightConnectionFormComponent, FlightOperatorFormComponent, FlightTimesFormComponent, FlightPriceFormComponent, JsonPipe],
1213
templateUrl: './flight-edit.component.html'
1314
})
1415
export class FlightEditComponent {
1516
store = inject(FlightEditStore);
16-
viewModel = this.store.getFlightEditVmAsPatchable();
17-
flightConnection = this.viewModel.flight.connection;
18-
flightTimes = this.viewModel.flight.times;
19-
flightOperator = this.viewModel.flight.operator;
20-
flightPrice = this.viewModel.flight.price;
17+
flightConnection = this.store.flightConnection;
18+
localFlightConnection = linkedSignal(() => this.store.flightEditVm.flight.connection());
19+
flightTimes = this.store.flightTimes;
20+
flightOperator = this.store.flightOperator;
21+
flightPrice = this.store.flightPrice;
2122

2223
_connectionCard = viewChild.required<ActionCardComponent>('connection');
2324
_timesCard = viewChild.required<ActionCardComponent>('times');

apps/playground/src/app/flight/flight-edit/flight-edit.store.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { withHypermediaResource, withHypermediaAction } from "@angular-architects/ngrx-hateoas";
1+
import { withHypermediaResource, withHypermediaAction, withWritableStateCopy, withExperimentalDeepWritableStateCopy, withExperimentalDeepWritableStateDelegate } from "@angular-architects/ngrx-hateoas";
22
import { signalStore } from "@ngrx/signals";
33
import { Aircraft, Flight, initialFlight } from "../flight.entities";
4-
import { effect } from "@angular/core";
54

65
export type FlightEditVm = {
76
flight: Flight;
@@ -16,8 +15,42 @@ export const initialFlightEditVm: FlightEditVm = {
1615
export const FlightEditStore = signalStore(
1716
{ providedIn: 'root' },
1817
withHypermediaResource('flightEditVm', initialFlightEditVm),
19-
withHypermediaAction('updateFlightConnection', store => store.flightEditVm.flight.connection, 'update'),
20-
withHypermediaAction('updateFlightTimes', store => store.flightEditVm.flight.times, 'update'),
21-
withHypermediaAction('updateFlightOperator', store => store.flightEditVm.flight.operator, 'update'),
22-
withHypermediaAction('updateFlightPrice', store => store.flightEditVm.flight.price, 'update')
18+
withWritableStateCopy(store => ({
19+
flightConnection: store.flightEditVm.flight.connection,
20+
flightTimes: store.flightEditVm.flight.times,
21+
flightOperator: store.flightEditVm.flight.operator,
22+
flightPrice: store.flightEditVm.flight.price
23+
})),
24+
withHypermediaAction('updateFlightConnection', store => store.flightConnection, 'update'),
25+
withHypermediaAction('updateFlightTimes', store => store.flightTimes, 'update'),
26+
withHypermediaAction('updateFlightOperator', store => store.flightOperator, 'update'),
27+
withHypermediaAction('updateFlightPrice', store => store.flightPrice, 'update')
2328
);
29+
30+
31+
// Option 1: Reading a resource from server, creating a writable copy, and sending back the copy to the server
32+
export const FlightEditViewStore1 = signalStore(
33+
{ providedIn: 'root' },
34+
withHypermediaResource('flightEditVm', initialFlightEditVm),
35+
withWritableStateCopy(store => ({ writableFlightConnection: store.flightEditVm.flight.connection })),
36+
withHypermediaAction('updateFlightConnection', store => store.writableFlightConnection, 'update'),
37+
);
38+
39+
// Option 2: Reading a resource from server, creating a deep writable copy (suited for template driven forms),
40+
// and sending back the copy to the server
41+
export const FlightEditViewStore2 = signalStore(
42+
{ providedIn: 'root' },
43+
withHypermediaResource('flightEditVm', initialFlightEditVm),
44+
withExperimentalDeepWritableStateCopy(store => ({ deepWritableFlightConnection: store.flightEditVm.flight.connection })),
45+
withHypermediaAction('updateFlightConnection', store => store.deepWritableFlightConnection, 'update'),
46+
);
47+
48+
// Option 3: Reading a resource from server, creating a delegate that directly modifies the "main" state of the store,
49+
// and sending back parts of the main state to the server
50+
export const FlightEditViewStore = signalStore(
51+
{ providedIn: 'root' },
52+
withHypermediaResource('flightEditVm', initialFlightEditVm),
53+
withExperimentalDeepWritableStateDelegate(store => ({ delegatedDeepWritableFlightConnection: store.flightEditVm.flight.connection })),
54+
withHypermediaAction('updateFlightConnection', store => store.delegatedDeepWritableFlightConnection, 'update'),
55+
);
56+

libs/ngrx-hateoas/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"name": "@angular-architects/ngrx-hateoas",
3-
"version": "20.1.0",
3+
"version": "21.0.0-beta.0",
44
"peerDependencies": {
5-
"@angular/common": "^20.0.0",
6-
"@angular/core": "^20.0.0",
7-
"@ngrx/signals": "^20.0.0"
5+
"@angular/common": "^21.0.0",
6+
"@angular/core": "^21.0.0",
7+
"@ngrx/signals": "^21.0.0"
88
},
99
"dependencies": {
1010
"tslib": "^2.3.0"
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { patchState, signalMethod, signalStore, withMethods, withState } from '@ngrx/signals';
3+
import { HypermediaResourceData } from './with-hypermedia-resource';
4+
import { Injector, provideZonelessChangeDetection, runInInjectionContext } from '@angular/core';
5+
import { firstValueFrom, timer } from 'rxjs';
6+
import { withExperimentalDeepWritableStateCopy } from './with-deep-writable-state-copy';
7+
8+
type TestModel = {
9+
numProp: number,
10+
objProp: {
11+
stringProp: string,
12+
numProp: number,
13+
},
14+
_links?: {
15+
self: { href: string }
16+
}
17+
}
18+
19+
const initialTestModel: TestModel = {
20+
numProp: 1,
21+
objProp: {
22+
stringProp: 'initial string',
23+
numProp: 42,
24+
}
25+
};
26+
27+
const testModelWithMetadata: TestModel = {
28+
numProp: 1,
29+
objProp: {
30+
stringProp: 'test model string',
31+
numProp: 50,
32+
_metaProp: 'Metadata'
33+
} as { stringProp: string, numProp: number, _metaProp: string }
34+
};
35+
36+
const TestStore = signalStore(
37+
{ providedIn: 'root' },
38+
withState<HypermediaResourceData<'model', TestModel>>({ model: initialTestModel }),
39+
withExperimentalDeepWritableStateCopy(store => ({
40+
writableObjProp: store.model.objProp,
41+
subObjectWithDeepStateCopies: {
42+
writableNumProp: store.model.objProp.numProp
43+
}
44+
})),
45+
withMethods(store => ({
46+
setModel(model: TestModel) {
47+
patchState(store, { model });
48+
}
49+
}))
50+
);
51+
52+
describe('withExperimentalDeepWritableStateCopy', () => {
53+
54+
let store: InstanceType<typeof TestStore>;
55+
let injector: Injector;
56+
57+
beforeEach(() => {
58+
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
59+
store = TestBed.inject(TestStore);
60+
injector = TestBed.inject(Injector);
61+
});
62+
63+
it('sets correct initial copy state', () => {
64+
expect(store.writableObjProp()).toBe(initialTestModel.objProp);
65+
expect(store.subObjectWithDeepStateCopies.writableNumProp()).toBe(initialTestModel.objProp.numProp);
66+
});
67+
68+
it('patches a signal inside the writable state', () => {
69+
const writableSignal = store.writableObjProp;
70+
71+
expect(store.model.objProp.stringProp()).toBe('initial string');
72+
expect(store.writableObjProp()).toBe(initialTestModel.objProp);
73+
74+
writableSignal.set({ stringProp: 'mutated string', numProp: 42 });
75+
76+
expect(store.model.objProp.stringProp()).toBe(initialTestModel.objProp.stringProp);
77+
expect(store.writableObjProp()).toEqual({ stringProp: 'mutated string', numProp: 42 });
78+
});
79+
80+
it('provides data which is not in type definition', () => {
81+
expect(store.model.objProp.stringProp()).toBe('initial string');
82+
expect(store.writableObjProp()).toBe(initialTestModel.objProp);
83+
84+
store.setModel(testModelWithMetadata);
85+
86+
expect(store.model.objProp.stringProp()).toBe('test model string');
87+
expect((store.model.objProp() as Record<string, unknown>)['_metaProp']).toBe('Metadata');
88+
expect(store.writableObjProp().stringProp).toBe('test model string');
89+
expect((store.writableObjProp() as Record<string, unknown>)['_metaProp']).toBe('Metadata');
90+
});
91+
92+
it('overrides state of writable copy', () => {
93+
expect(store.model.objProp.stringProp()).toBe('initial string');
94+
expect(store.writableObjProp()).toBe(initialTestModel.objProp);
95+
96+
store.writableObjProp.set({ stringProp: 'overridden string', numProp: 42 });
97+
98+
expect(store.model.objProp.stringProp()).toBe('initial string');
99+
expect(store.writableObjProp().stringProp).toBe('overridden string');
100+
101+
store.setModel(testModelWithMetadata);
102+
103+
expect(store.model.objProp.stringProp()).toBe('test model string');
104+
expect(store.writableObjProp().stringProp).toBe('test model string');
105+
});
106+
107+
it('triggers deep computed signals on writable state copy', async () => {
108+
109+
let stringPropTriggered = false;
110+
let numPropTriggered = false;
111+
112+
runInInjectionContext(injector, () => {
113+
signalMethod(() => stringPropTriggered = true)(store.writableObjProp.stringProp);
114+
signalMethod(() => numPropTriggered = true)(store.writableObjProp.numProp);
115+
});
116+
117+
await firstValueFrom(timer(0));
118+
stringPropTriggered = false;
119+
numPropTriggered = false;
120+
121+
store.writableObjProp.set({ stringProp: 'overridden string', numProp: 42 });
122+
123+
await firstValueFrom(timer(10));
124+
125+
expect(stringPropTriggered).toBe(true);
126+
expect(numPropTriggered).toBe(false);
127+
128+
stringPropTriggered = false;
129+
numPropTriggered = false;
130+
131+
store.writableObjProp.set({ stringProp: 'next overridden string', numProp: 100 });
132+
133+
await firstValueFrom(timer(0));
134+
135+
expect(stringPropTriggered).toBe(true);
136+
expect(numPropTriggered).toBe(true);
137+
});
138+
139+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { isSignal, linkedSignal, Signal } from "@angular/core";
2+
import { signalStoreFeature, SignalStoreFeature, SignalStoreFeatureResult, StateSignals, withProps } from "@ngrx/signals";
3+
import { deepWritableFromSignal, DeepWritableSignal } from "../util/deep-writeable-signal";
4+
5+
type StoreForDeepWritableStateCopy<Input extends SignalStoreFeatureResult> = StateSignals<Input['state']>;
6+
7+
export type ObjectWithSignalsForDeepStateCopy = {
8+
[key: string]: Signal<unknown> | ObjectWithSignalsForDeepStateCopy;
9+
}
10+
11+
export type DeepWritableStateCopy<State extends ObjectWithSignalsForDeepStateCopy> = {
12+
[Key in keyof State]: State[Key] extends ObjectWithSignalsForDeepStateCopy ?
13+
DeepWritableStateCopy<State[Key]>
14+
: State[Key] extends Signal<infer InnerType> ?
15+
DeepWritableSignal<InnerType> : never;
16+
};
17+
18+
type SelectStateFn<Input extends SignalStoreFeatureResult, StateSelection extends ObjectWithSignalsForDeepStateCopy> = (store: StoreForDeepWritableStateCopy<Input>) => StateSelection
19+
20+
function toDeepWritableStateCopy<T extends ObjectWithSignalsForDeepStateCopy>(stateSelection: T): DeepWritableStateCopy<T> {
21+
const result: ObjectWithSignalsForDeepStateCopy = {};
22+
for (const key in stateSelection) {
23+
const value = stateSelection[key];
24+
25+
if(isSignal(value)) {
26+
const stateCopy = linkedSignal(() => value());
27+
result[key] = deepWritableFromSignal(stateCopy);
28+
} else {
29+
result[key] = toDeepWritableStateCopy(value as ObjectWithSignalsForDeepStateCopy);
30+
}
31+
}
32+
return result as DeepWritableStateCopy<T>;
33+
}
34+
35+
export function withExperimentalDeepWritableStateCopy<Input extends SignalStoreFeatureResult, StateSelection extends ObjectWithSignalsForDeepStateCopy>(
36+
stateMapFn: SelectStateFn<Input, StateSelection>
37+
): SignalStoreFeature<
38+
Input,
39+
Input & {
40+
props: DeepWritableStateCopy<ReturnType<SelectStateFn<Input, StateSelection>>>;
41+
}
42+
>;
43+
export function withExperimentalDeepWritableStateCopy<Input extends SignalStoreFeatureResult, StateSelection extends ObjectWithSignalsForDeepStateCopy>(
44+
stateMapFn: SelectStateFn<Input, StateSelection>
45+
) {
46+
return signalStoreFeature(
47+
withProps(store => toDeepWritableStateCopy(stateMapFn(store as unknown as StoreForDeepWritableStateCopy<Input>)))
48+
);
49+
}

0 commit comments

Comments
 (0)