Skip to content

Commit 8bb9b3f

Browse files
Enhance flight edit component with reactive URL connection and update store features
1 parent b65699e commit 8bb9b3f

10 files changed

Lines changed: 190 additions & 46 deletions

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

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,19 @@
66

77
<div class="row">
88
<div class="col">
9-
<app-action-card #connection
10-
[disabled]="!store.updateFlightConnectionState.isAvailable()"
11-
(save)="onUpdateConnection()">
9+
<app-action-card #connection [disabled]="!store.updateFlightConnectionState.isAvailable()" (save)="onUpdateConnection()">
1210
<app-flight-connection-form [connection]="flightForm.connection" />
1311
</app-action-card>
14-
<app-action-card #times
15-
[disabled]="!store.updateFlightTimesState.isAvailable()"
16-
(save)="onUpdateTimes()">
12+
<app-action-card #times [disabled]="!store.updateFlightTimesState.isAvailable()" (save)="onUpdateTimes()">
1713
<app-flight-times-form [times]="flightForm.times" />
1814
</app-action-card>
19-
<app-action-card #operator
20-
[disabled]="!store.updateFlightOperatorState.isAvailable()"
21-
(save)="onUpdateOperator()">
15+
<app-action-card #operator [disabled]="!store.updateFlightOperatorState.isAvailable()" (save)="onUpdateOperator()">
2216
<app-flight-operator-form [aircrafts]="store.flightEditVm.aircrafts()" [operator]="flightForm.operator" />
2317
</app-action-card>
2418
@if(flightForm.price().value(); as flightPriceValue) {
25-
<app-action-card #price
26-
[disabled]="!store.updateFlightPriceState.isAvailable()"
27-
(save)="onUpdatePrice()">
28-
<app-flight-price-form [price]="$any(flightForm.price)" />
29-
</app-action-card>
19+
<app-action-card #price [disabled]="!store.updateFlightPriceState.isAvailable()" (save)="onUpdatePrice()">
20+
<app-flight-price-form [price]="$any(flightForm.price)" />
21+
</app-action-card>
3022
}
3123
</div>
3224
</div>

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import { FlightTimesFormComponent } from '../shared/flight-times-form/flight-tim
77
import { FlightEditStore } from './flight-edit.store';
88
import { flightSchema } from '../flight.entities';
99
import { form } from '@angular/forms/signals';
10+
import { HasActionPipe } from '@angular-architects/ngrx-hateoas';
1011

1112
@Component({
1213
selector: 'app-flight-edit',
13-
imports: [ActionCardComponent, FlightConnectionFormComponent, FlightOperatorFormComponent, FlightTimesFormComponent, FlightPriceFormComponent],
14+
imports: [ActionCardComponent, FlightConnectionFormComponent, FlightOperatorFormComponent, FlightTimesFormComponent, FlightPriceFormComponent, HasActionPipe],
1415
templateUrl: './flight-edit.component.html'
1516
})
1617
export class FlightEditComponent {
@@ -22,6 +23,7 @@ export class FlightEditComponent {
2223
_operatorCard = viewChild.required<ActionCardComponent>('operator');
2324
_priceCard = viewChild<ActionCardComponent>('price');
2425

26+
2527
async onUpdateConnection() {
2628
try {
2729
await this.store.updateFlightConnection();
@@ -31,27 +33,27 @@ export class FlightEditComponent {
3133
}
3234
}
3335

34-
onUpdateTimes() {
36+
async onUpdateTimes() {
3537
try {
36-
this.store.updateFlightTimes();
38+
await this.store.updateFlightTimes();
3739
this._timesCard().showSuccess();
3840
} catch {
3941
this._timesCard().showError();
4042
}
4143
}
4244

43-
onUpdateOperator() {
45+
async onUpdateOperator() {
4446
try {
45-
this.store.updateFlightOperator();
47+
await this.store.updateFlightOperator();
4648
this._operatorCard().showSuccess();
4749
} catch {
4850
this._operatorCard().showError();
4951
}
5052
}
5153

52-
onUpdatePrice() {
54+
async onUpdatePrice() {
5355
try {
54-
this.store.updateFlightPrice();
56+
await this.store.updateFlightPrice();
5557
this._priceCard()?.showSuccess();
5658
} catch {
5759
this._priceCard()?.showError();
Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { withHypermediaResource, withHypermediaAction, withWritableStateCopy, withExperimentalDeepWritableStateCopy, withExperimentalDeepWritableStateDelegate } from "@angular-architects/ngrx-hateoas";
2-
import { signalStore } from "@ngrx/signals";
2+
import { signalStore, withMethods } from "@ngrx/signals";
33
import { Aircraft, Flight, initialFlight } from "../flight.entities";
44

55
export type FlightEditVm = {
@@ -15,39 +15,42 @@ export const initialFlightEditVm: FlightEditVm = {
1515
export const FlightEditStore = signalStore(
1616
{ providedIn: 'root' },
1717
withHypermediaResource('flightEditVm', initialFlightEditVm),
18-
withWritableStateCopy(store => ({
19-
localFlight: store.flightEditVm.flight
20-
})),
18+
withWritableStateCopy(store => ({ localFlight: store.flightEditVm.flight })),
2119
withHypermediaAction('updateFlightConnection', store => store.localFlight.connection, 'update'),
2220
withHypermediaAction('updateFlightTimes', store => store.localFlight.times, 'update'),
2321
withHypermediaAction('updateFlightOperator', store => store.localFlight.operator, 'update'),
24-
withHypermediaAction('updateFlightPrice', store => store.localFlight.price, 'update')
22+
withHypermediaAction('updateFlightPrice', store => store.localFlight.price, 'update'),
23+
withMethods(store => ({
24+
reset() {
25+
store.localFlight.set(store.flightEditVm.flight());
26+
}
27+
}))
2528
);
2629

2730

2831
// Option 1: Reading a resource from server, creating a writable copy, and sending back the copy to the server
2932
export const FlightEditViewStore1 = signalStore(
3033
{ providedIn: 'root' },
3134
withHypermediaResource('flightEditVm', initialFlightEditVm),
32-
withWritableStateCopy(store => ({ writableFlightConnection: store.flightEditVm.flight.connection })),
33-
withHypermediaAction('updateFlightConnection', store => store.writableFlightConnection, 'update'),
35+
withWritableStateCopy(store => ({ writableFlightConnectionCopy: store.flightEditVm.flight.connection })),
36+
withHypermediaAction('updateFlightConnection', store => store.writableFlightConnectionCopy, 'update'),
3437
);
3538

3639
// Option 2: Reading a resource from server, creating a deep writable copy (suited for template driven forms),
3740
// and sending back the copy to the server
3841
export const FlightEditViewStore2 = signalStore(
3942
{ providedIn: 'root' },
4043
withHypermediaResource('flightEditVm', initialFlightEditVm),
41-
withExperimentalDeepWritableStateCopy(store => ({ deepWritableFlightConnection: store.flightEditVm.flight.connection })),
42-
withHypermediaAction('updateFlightConnection', store => store.deepWritableFlightConnection, 'update'),
44+
withExperimentalDeepWritableStateCopy(store => ({ deepWritableFlightConnectionCopy: store.flightEditVm.flight.connection })),
45+
withHypermediaAction('updateFlightConnection', store => store.deepWritableFlightConnectionCopy, 'update'),
4346
);
4447

4548
// Option 3: Reading a resource from server, creating a delegate that directly modifies the "main" state of the store,
4649
// and sending back parts of the main state to the server
47-
export const FlightEditViewStore = signalStore(
50+
export const FlightEditViewStore3 = signalStore(
4851
{ providedIn: 'root' },
4952
withHypermediaResource('flightEditVm', initialFlightEditVm),
50-
withExperimentalDeepWritableStateDelegate(store => ({ delegatedDeepWritableFlightConnection: store.flightEditVm.flight.connection })),
51-
withHypermediaAction('updateFlightConnection', store => store.delegatedDeepWritableFlightConnection, 'update'),
53+
withExperimentalDeepWritableStateDelegate(store => ({ deepWritableFlightConnectionDelegate: store.flightEditVm.flight.connection })),
54+
withHypermediaAction('updateFlightConnection', store => store.flightEditVm.flight.connection, 'update'),
5255
);
5356

doc/docs/guide/04-loading_features/01-withHypermediaResource.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,12 @@ Loads the resource from the provided URL.
6464
```ts
6565
reload<resourceName>(): Promise<void>
6666
```
67-
Reloads the resource from the last used URL.
67+
Reloads the resource from the last used URL.
68+
69+
### Connect the Resource to a URL Signal
70+
```ts
71+
connect<resourceName>ToUrl(url: string | Signal<string>): EffectRef
72+
```
73+
Reactively connects the resource to a URL signal. Whenever the signal emits a new URL value, the resource is automatically loaded from that URL. If a plain `string` is passed instead of a signal, the resource is loaded once from that URL.
74+
75+
* **url**: A `Signal<string>` whose value is used as the URL. Whenever the signal changes, the resource is reloaded automatically. Can also be a plain `string` for a one-time load.

doc/docs/guide/04-loading_features/02-withInitialHypermediaResource.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ Creates a resource in the store which gets automatically loaded when an instance
88
## API
99
```ts
1010
function withInitialHypermediaResource<ResourceName extends string, TResource>(
11-
resourceName: ResourceName, initialValue: TResource, url: string | (() => string)): SignalStoreFeature;
11+
resourceName: ResourceName, initialValue: TResource, url: string | Promise<string> | (() => string | Promise<string>)): SignalStoreFeature;
1212
```
1313

1414
* **resourceName**: The name of how the resource will be declared in the store.
1515
* **initialValue**: The initial value of the resource before it is loaded from a URL.
16-
* **url**: The URL from which the resource will be loaded. Can also be a function returning a string. If a function is provided, it will be executed at the time the store is created.
16+
* **url**: The URL from which the resource will be loaded. Accepts four forms:
17+
- A `string`: the URL is used directly at store creation time.
18+
- A `Promise<string>`: the promise is awaited and the resolved value is used as the URL.
19+
- A function returning a `string`: the function is called at store creation time, allowing injection of Angular services (e.g. `() => inject(MyToken)`).
20+
- A function returning a `Promise<string>`: the function is called at store creation time and the resulting promise is awaited before loading. Useful for asynchronous URL resolution via injected services.
1721

1822
## State
1923
With this feature the following state properties are added to the interface of the store:
@@ -65,4 +69,12 @@ Loads the resource from the provided URL.
6569
```ts
6670
reload<resourceName>(): Promise<void>
6771
```
68-
Reloads the resource from the last used URL.
72+
Reloads the resource from the last used URL.
73+
74+
### Connect the Resource to a URL Signal
75+
```ts
76+
connect<resourceName>ToUrl(url: string | Signal<string>): EffectRef
77+
```
78+
Reactively connects the resource to a URL signal. Whenever the signal emits a new URL value, the resource is automatically loaded from that URL. If a plain `string` is passed instead of a signal, the resource is loaded once from that URL.
79+
80+
* **url**: A `Signal<string>` whose value is used as the URL. Whenever the signal changes, the resource is reloaded automatically. Can also be a plain `string` for a one-time load.

libs/ngrx-hateoas/src/lib/store-features/with-deep-writable-state-copy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { isSignal, linkedSignal, Signal } from "@angular/core";
22
import { signalStoreFeature, SignalStoreFeature, SignalStoreFeatureResult, StateSignals, withProps } from "@ngrx/signals";
33
import { deepWritableFromSignal, DeepWritableSignal } from "../util/deep-writeable-signal";
44

5-
type StoreForDeepWritableStateCopy<Input extends SignalStoreFeatureResult> = StateSignals<Input['state']>;
5+
type StoreForDeepWritableStateCopy<Input extends SignalStoreFeatureResult> = StateSignals<Input['state']> & Input['props'];
66

77
export type ObjectWithSignalsForDeepStateCopy = {
88
[key: string]: Signal<unknown> | ObjectWithSignalsForDeepStateCopy;

libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-resource.spec.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
44
import { signalStore } from '@ngrx/signals';
55
import { withHypermediaResource } from './with-hypermedia-resource';
66
import { provideHateoas } from '../provide';
7-
import { provideZonelessChangeDetection } from '@angular/core';
7+
import { provideZonelessChangeDetection, signal } from '@angular/core';
88

99
type RootModel = {
1010
apiName: string
@@ -88,6 +88,7 @@ describe('withHypermediaResource', () => {
8888
it('has correct resource methods', () => {
8989
expect(store.loadTestModelFromLink).toBeDefined();
9090
expect(store.loadTestModelFromUrl).toBeDefined();
91+
expect(store.connectTestModelToUrl).toBeDefined();
9192
expect(store.reloadTestModel).toBeDefined();
9293
expect(store.getTestModelAsPatchable).toBeDefined();
9394
});
@@ -312,4 +313,62 @@ describe('withHypermediaResource', () => {
312313
httpTestingController.verify();
313314
});
314315

316+
it('connects resource to a static url and loads it', (done: DoneFn) => {
317+
const resourceFromUrl: TestModel = {
318+
numProp: 2,
319+
objProp: { stringProp: 'from connect' }
320+
};
321+
322+
store.connectTestModelToUrl('api/test-model');
323+
324+
expect(store.testModelState.isLoading()).toBeTrue();
325+
expect(store.testModelState.isLoaded()).toBeFalse();
326+
327+
httpTestingController.expectOne('api/test-model').flush(resourceFromUrl);
328+
httpTestingController.verify();
329+
330+
setTimeout(() => {
331+
expect(store.testModelState.url()).toBe('api/test-model');
332+
expect(store.testModelState.isLoading()).toBeFalse();
333+
expect(store.testModelState.isLoaded()).toBeTrue();
334+
expect(store.testModel.objProp.stringProp()).toBe('from connect');
335+
done();
336+
}, 0);
337+
});
338+
339+
it('connects resource reactively to a url signal and reloads when signal changes', (done: DoneFn) => {
340+
const resourceFirst: TestModel = {
341+
numProp: 2,
342+
objProp: { stringProp: 'first response' }
343+
};
344+
const resourceSecond: TestModel = {
345+
numProp: 3,
346+
objProp: { stringProp: 'second response' }
347+
};
348+
349+
const urlSignal = signal('api/test-model?version=1');
350+
store.connectTestModelToUrl(urlSignal);
351+
352+
TestBed.flushEffects();
353+
354+
expect(store.testModelState.isLoading()).toBeTrue();
355+
httpTestingController.expectOne('api/test-model?version=1').flush(resourceFirst);
356+
357+
setTimeout(() => {
358+
expect(store.testModel.objProp.stringProp()).toBe('first response');
359+
360+
urlSignal.set('api/test-model?version=2');
361+
TestBed.flushEffects();
362+
363+
expect(store.testModelState.isLoading()).toBeTrue();
364+
httpTestingController.expectOne('api/test-model?version=2').flush(resourceSecond);
365+
366+
setTimeout(() => {
367+
expect(store.testModel.objProp.stringProp()).toBe('second response');
368+
httpTestingController.verify();
369+
done();
370+
}, 0);
371+
}, 0);
372+
});
373+
315374
});

libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-resource.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { inject, Signal } from "@angular/core";
2-
import { SignalStoreFeature, patchState, signalStoreFeature, withMethods, withState } from "@ngrx/signals";
2+
import { SignalMethod, SignalStoreFeature, patchState, signalMethod, signalStoreFeature, withMethods, withState } from "@ngrx/signals";
33
import { DeepPatchableSignal, toDeepPatchableSignal } from "../util/deep-patchable-signal";
44
import { HateoasService } from "../services/hateoas.service";
55
import { RequestService } from "../services/request.service";
@@ -39,6 +39,14 @@ export function generateLoadHypermediaResourceFromLinkMethodName(resourceName: s
3939
return `load${resourceName.charAt(0).toUpperCase() + resourceName.slice(1)}FromLink`;
4040
}
4141

42+
export type ConnectHypermediaResourceToUrlMethod<ResourceName extends string> = {
43+
[K in ResourceName as `connect${Capitalize<ResourceName>}ToUrl`]: SignalMethod<string>
44+
};
45+
46+
export function generateConnectHypermediaResourceToUrlMethodName(resourceName: string) {
47+
return `connect${resourceName.charAt(0).toUpperCase() + resourceName.slice(1)}ToUrl`;
48+
}
49+
4250
export type ReloadHypermediaResourceMethod<ResourceName extends string> = {
4351
[K in ResourceName as `reload${Capitalize<ResourceName>}`]: () => Promise<void>
4452
};
@@ -58,6 +66,7 @@ export function generateGetAsPatchableHypermediaResourceMethodName(resourceName:
5866
export type HypermediaResourceStoreMethods<ResourceName extends string, TResource> =
5967
LoadHypermediaResourceFromUrlMethod<ResourceName>
6068
& LoadHypermediaResourceFromLinkMethod<ResourceName>
69+
& ConnectHypermediaResourceToUrlMethod<ResourceName>
6170
& ReloadHypermediaResourceMethod<ResourceName>
6271
& GetAsPatchableHypermediaResourceMethod<ResourceName, TResource>;
6372

@@ -99,6 +108,7 @@ export function withHypermediaResource<ResourceName extends string, TResource>(r
99108
const stateKey = `${resourceName}State`;
100109
const loadFromUrlMethodName = generateLoadHypermediaResourceFromUrlMethodName(resourceName);
101110
const loadFromLinkMethodName = generateLoadHypermediaResourceFromLinkMethodName(resourceName);
111+
const connectToUrlMethodName = generateConnectHypermediaResourceToUrlMethodName(resourceName);
102112
const reloadMethodName = generateReloadHypermediaResourceMethodName(resourceName);
103113
const getAsPatchableMethodName = generateGetAsPatchableHypermediaResourceMethodName(resourceName);
104114

@@ -166,6 +176,8 @@ export function withHypermediaResource<ResourceName extends string, TResource>(r
166176
}
167177
};
168178

179+
const connectToUrlMethod = signalMethod<string>(url => loadFromUrlMethod(url));
180+
169181
const reloadMethod = (): Promise<void> => {
170182
if (currentRequestSub) {
171183
currentRequestSub.unsubscribe();
@@ -207,6 +219,7 @@ export function withHypermediaResource<ResourceName extends string, TResource>(r
207219
return {
208220
[loadFromUrlMethodName]: loadFromUrlMethod,
209221
[loadFromLinkMethodName]: loadFromLinkMethod,
222+
[connectToUrlMethodName]: connectToUrlMethod,
210223
[reloadMethodName]: reloadMethod,
211224
[getAsPatchableMethodName]: getAsPatchableMethod
212225
};

libs/ngrx-hateoas/src/lib/store-features/with-initial-hypermedia-resource.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ const TestStoreWithInjectedUrl = signalStore(
2929
withInitialHypermediaResource('rootModel', initialRootModel, () => inject(ROOT_URL))
3030
);
3131

32+
const TestStoreWithPromiseUrl = signalStore(
33+
{ providedIn: 'root' },
34+
withInitialHypermediaResource('rootModel', initialRootModel, Promise.resolve('/api/promised'))
35+
);
36+
37+
const TestStoreWithAsyncResolverUrl = signalStore(
38+
{ providedIn: 'root' },
39+
withInitialHypermediaResource('rootModel', initialRootModel, () => Promise.resolve('/api/async-resolved'))
40+
);
41+
3242
describe('withInitialHypermediaResource', () => {
3343

3444
let httpTestingController: HttpTestingController;
@@ -65,4 +75,38 @@ describe('withInitialHypermediaResource', () => {
6575
done();
6676
}, 0);
6777
});
78+
79+
it('requests model from promise url automatically and provides correct states', (done: DoneFn) => {
80+
const store = TestBed.inject(TestStoreWithPromiseUrl);
81+
// isLoading is not immediately true because loadFromUrl is called after the promise resolves
82+
expect(store.rootModelState.isLoaded()).toBeFalse();
83+
expect(store.rootModelState.isLoading()).toBeFalse();
84+
setTimeout(() => {
85+
expect(store.rootModelState.isLoading()).toBeTrue();
86+
httpTestingController.expectOne('/api/promised').flush(initialRootModel);
87+
httpTestingController.verify();
88+
setTimeout(() => {
89+
expect(store.rootModelState.isLoaded()).toBeTrue();
90+
expect(store.rootModelState.isLoading()).toBeFalse();
91+
done();
92+
}, 0);
93+
}, 0);
94+
});
95+
96+
it('requests model from async resolver url automatically and provides correct states', (done: DoneFn) => {
97+
const store = TestBed.inject(TestStoreWithAsyncResolverUrl);
98+
// isLoading is not immediately true because loadFromUrl is called after the promise resolves
99+
expect(store.rootModelState.isLoaded()).toBeFalse();
100+
expect(store.rootModelState.isLoading()).toBeFalse();
101+
setTimeout(() => {
102+
expect(store.rootModelState.isLoading()).toBeTrue();
103+
httpTestingController.expectOne('/api/async-resolved').flush(initialRootModel);
104+
httpTestingController.verify();
105+
setTimeout(() => {
106+
expect(store.rootModelState.isLoaded()).toBeTrue();
107+
expect(store.rootModelState.isLoading()).toBeFalse();
108+
done();
109+
}, 0);
110+
}, 0);
111+
});
68112
});

0 commit comments

Comments
 (0)