Skip to content

Commit 5e6e41e

Browse files
Refactor hypermedia resource methods to support reactive URL loading and remove deprecated connection method
1 parent 8bb9b3f commit 5e6e41e

5 files changed

Lines changed: 106 additions & 78 deletions

File tree

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ 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';
1110

1211
@Component({
1312
selector: 'app-flight-edit',
14-
imports: [ActionCardComponent, FlightConnectionFormComponent, FlightOperatorFormComponent, FlightTimesFormComponent, FlightPriceFormComponent, HasActionPipe],
13+
imports: [ActionCardComponent, FlightConnectionFormComponent, FlightOperatorFormComponent, FlightTimesFormComponent, FlightPriceFormComponent],
1514
templateUrl: './flight-edit.component.html'
1615
})
1716
export class FlightEditComponent {

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

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@ With this feature the following methods are added to the interface of the store:
4545
### Load the Resource from an URL
4646
```ts
4747
load<resourceName>FromUrl(url: string | null, fromCache?: boolean): Promise<void>
48+
load<resourceName>FromUrl(url: Signal<string | null>): EffectRef
4849
```
49-
Loads the resource from the provided URL.
50+
Loads the resource from the provided URL. Two overloads are available:
5051

51-
* **url**: The URL from which the resource should be loaded. If `null` is provided the resource is reset to its initial value.
52-
* **fromCache**: Whether to load the resource even if the the current state is loaded from the same URL. If not provided, defaults to `false`.
52+
* When called with a **plain value** (`string | null`): loads the resource immediately and returns a `Promise<void>` that resolves when the request completes. Passing `null` resets the resource to its initial value.
53+
* **fromCache**: When `true`, skips loading if the resource is already loaded from the same URL. Defaults to `false`.
54+
* When called with a **`Signal<string | null>`**: sets up a reactive effect that automatically reloads the resource whenever the signal's value changes, and returns an `EffectRef` that can be used to destroy the effect.
5355

5456
### Load the Resource from a Link
5557
```ts
@@ -65,11 +67,3 @@ Loads the resource from the provided URL.
6567
reload<resourceName>(): Promise<void>
6668
```
6769
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: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,13 @@ With this feature the following methods are added to the interface of the store:
5050
### Load the Resource from an URL
5151
```ts
5252
load<resourceName>FromUrl(url: string | null, fromCache?: boolean): Promise<void>
53+
load<resourceName>FromUrl(url: Signal<string | null>): EffectRef
5354
```
54-
Loads the resource from the provided URL.
55+
Loads the resource from the provided URL. Two overloads are available:
5556

56-
* **url**: The URL from which the resource should be loaded. If `null` is provided the resource is reset to its initial value.
57-
* **fromCache**: Whether to load the resource even if the the current state is loaded from the same URL. If not provided, defaults to `false`.
57+
* When called with a **plain value** (`string | null`): loads the resource immediately and returns a `Promise<void>` that resolves when the request completes. Passing `null` resets the resource to its initial value.
58+
* **fromCache**: When `true`, skips loading if the resource is already loaded from the same URL. Defaults to `false`.
59+
* When called with a **`Signal<string | null>`**: sets up a reactive effect that automatically reloads the resource whenever the signal's value changes, and returns an `EffectRef` that can be used to destroy the effect.
5860

5961
### Load the Resource from a Link
6062
```ts
@@ -69,12 +71,4 @@ Loads the resource from the provided URL.
6971
```ts
7072
reload<resourceName>(): Promise<void>
7173
```
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.
74+
Reloads the resource from the last used URL.

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

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ describe('withHypermediaResource', () => {
8888
it('has correct resource methods', () => {
8989
expect(store.loadTestModelFromLink).toBeDefined();
9090
expect(store.loadTestModelFromUrl).toBeDefined();
91-
expect(store.connectTestModelToUrl).toBeDefined();
9291
expect(store.reloadTestModel).toBeDefined();
9392
expect(store.getTestModelAsPatchable).toBeDefined();
9493
});
@@ -210,6 +209,75 @@ describe('withHypermediaResource', () => {
210209
expect(store.testModel.objProp.stringProp()).toBe('from Url Reloaded');
211210
});
212211

212+
it('skips loading if fromCache is true and resource is already loaded from the same url', async () => {
213+
const resourceFromUrl: TestModel = {
214+
numProp: 2,
215+
objProp: { stringProp: 'from Url' }
216+
};
217+
218+
const loadPromise = store.loadTestModelFromUrl('api/test-model');
219+
httpTestingController.expectOne('api/test-model').flush(resourceFromUrl);
220+
await loadPromise;
221+
222+
// second call with same url and fromCache=true — should not trigger a new request
223+
await store.loadTestModelFromUrl('api/test-model', true);
224+
225+
httpTestingController.expectNone('api/test-model');
226+
httpTestingController.verify();
227+
228+
expect(store.testModelState.isLoaded()).toBeTrue();
229+
expect(store.testModel.objProp.stringProp()).toBe('from Url');
230+
});
231+
232+
it('loads again if fromCache is true but url has changed', async () => {
233+
const resourceFromUrl: TestModel = {
234+
numProp: 2,
235+
objProp: { stringProp: 'from Url' }
236+
};
237+
238+
const resourceFromNewUrl: TestModel = {
239+
numProp: 3,
240+
objProp: { stringProp: 'from New Url' }
241+
};
242+
243+
const loadPromise = store.loadTestModelFromUrl('api/test-model');
244+
httpTestingController.expectOne('api/test-model').flush(resourceFromUrl);
245+
await loadPromise;
246+
247+
// call with a different url and fromCache=true — should still load
248+
const reloadPromise = store.loadTestModelFromUrl('api/test-model?v=2', true);
249+
httpTestingController.expectOne('api/test-model?v=2').flush(resourceFromNewUrl);
250+
httpTestingController.verify();
251+
await reloadPromise;
252+
253+
expect(store.testModelState.url()).toBe('api/test-model?v=2');
254+
expect(store.testModel.objProp.stringProp()).toBe('from New Url');
255+
});
256+
257+
it('loads again if fromCache is false even when already loaded from the same url', async () => {
258+
const resourceFromUrl: TestModel = {
259+
numProp: 2,
260+
objProp: { stringProp: 'from Url' }
261+
};
262+
263+
const resourceReloaded: TestModel = {
264+
numProp: 3,
265+
objProp: { stringProp: 'reloaded' }
266+
};
267+
268+
let loadPromise = store.loadTestModelFromUrl('api/test-model');
269+
httpTestingController.expectOne('api/test-model').flush(resourceFromUrl);
270+
await loadPromise;
271+
272+
// call with same url and fromCache=false (default) — should load again
273+
loadPromise = store.loadTestModelFromUrl('api/test-model');
274+
httpTestingController.expectOne('api/test-model').flush(resourceReloaded);
275+
httpTestingController.verify();
276+
await loadPromise;
277+
278+
expect(store.testModel.objProp.stringProp()).toBe('reloaded');
279+
});
280+
213281
it('gets the resource as patchable', () => {
214282
const resource = store.getTestModelAsPatchable();
215283

@@ -264,16 +332,11 @@ describe('withHypermediaResource', () => {
264332
const req1 = httpTestingController.expectOne('api/test-model?first=1');
265333
expect(req1.cancelled).toBeTrue();
266334

267-
// flush second request first
335+
// flush second request
268336
httpTestingController.expectOne('api/test-model?second=2').flush(resourceSecond);
269337
await request;
270338

271-
// after second resolves, store should reflect second response
272-
expect(store.testModel.objProp.stringProp()).toBe('second response');
273-
274-
// assert that the resource still contains the data from the second url
275339
expect(store.testModel.objProp.stringProp()).toBe('second response');
276-
277340
httpTestingController.verify();
278341
});
279342

@@ -313,30 +376,7 @@ describe('withHypermediaResource', () => {
313376
httpTestingController.verify();
314377
});
315378

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) => {
379+
it('loads the resource reactively from a url signal and reloads when signal changes', (done: DoneFn) => {
340380
const resourceFirst: TestModel = {
341381
numProp: 2,
342382
objProp: { stringProp: 'first response' }
@@ -347,7 +387,7 @@ describe('withHypermediaResource', () => {
347387
};
348388

349389
const urlSignal = signal('api/test-model?version=1');
350-
store.connectTestModelToUrl(urlSignal);
390+
store.loadTestModelFromUrl(urlSignal);
351391

352392
TestBed.flushEffects();
353393

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

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { inject, Signal } from "@angular/core";
2-
import { SignalMethod, SignalStoreFeature, patchState, signalMethod, signalStoreFeature, withMethods, withState } from "@ngrx/signals";
1+
import { inject, isSignal, EffectRef, Signal } from "@angular/core";
2+
import { 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";
@@ -24,7 +24,10 @@ export type HypermediaResourceStoreState<ResourceName extends string, TResource>
2424
& HypermediaResourceState<ResourceName>;
2525

2626
export type LoadHypermediaResourceFromUrlMethod<ResourceName extends string> = {
27-
[K in ResourceName as `load${Capitalize<ResourceName>}FromUrl`]: (url: string | null, fromCache?: boolean) => Promise<void>
27+
[K in ResourceName as `load${Capitalize<ResourceName>}FromUrl`]: {
28+
(url: string | null, fromCache?: boolean): Promise<void>;
29+
(url: Signal<string | null>): EffectRef;
30+
}
2831
};
2932

3033
export function generateLoadHypermediaResourceFromUrlMethodName(resourceName: string) {
@@ -39,14 +42,6 @@ export function generateLoadHypermediaResourceFromLinkMethodName(resourceName: s
3942
return `load${resourceName.charAt(0).toUpperCase() + resourceName.slice(1)}FromLink`;
4043
}
4144

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-
5045
export type ReloadHypermediaResourceMethod<ResourceName extends string> = {
5146
[K in ResourceName as `reload${Capitalize<ResourceName>}`]: () => Promise<void>
5247
};
@@ -66,7 +61,6 @@ export function generateGetAsPatchableHypermediaResourceMethodName(resourceName:
6661
export type HypermediaResourceStoreMethods<ResourceName extends string, TResource> =
6762
LoadHypermediaResourceFromUrlMethod<ResourceName>
6863
& LoadHypermediaResourceFromLinkMethod<ResourceName>
69-
& ConnectHypermediaResourceToUrlMethod<ResourceName>
7064
& ReloadHypermediaResourceMethod<ResourceName>
7165
& GetAsPatchableHypermediaResourceMethod<ResourceName, TResource>;
7266

@@ -108,7 +102,6 @@ export function withHypermediaResource<ResourceName extends string, TResource>(r
108102
const stateKey = `${resourceName}State`;
109103
const loadFromUrlMethodName = generateLoadHypermediaResourceFromUrlMethodName(resourceName);
110104
const loadFromLinkMethodName = generateLoadHypermediaResourceFromLinkMethodName(resourceName);
111-
const connectToUrlMethodName = generateConnectHypermediaResourceToUrlMethodName(resourceName);
112105
const reloadMethodName = generateReloadHypermediaResourceMethodName(resourceName);
113106
const getAsPatchableMethodName = generateGetAsPatchableHypermediaResourceMethodName(resourceName);
114107

@@ -130,7 +123,7 @@ export function withHypermediaResource<ResourceName extends string, TResource>(r
130123

131124
let currentRequestSub: Subscription | undefined;
132125

133-
const loadFromUrlMethod = (url: string | null, fromCache = false): Promise<void> => {
126+
function loadFromUrl(url: string | null, fromCache = false): Promise<void> {
134127
if (currentRequestSub) {
135128
currentRequestSub.unsubscribe();
136129
currentRequestSub = undefined;
@@ -140,7 +133,9 @@ export function withHypermediaResource<ResourceName extends string, TResource>(r
140133
updateData(dataKey, initialValue),
141134
updateState(stateKey, { url: '', isLoading: false, isLoaded: false }));
142135
return Promise.resolve();
143-
} else if (!fromCache || hateoasService.getLink(getData(store, dataKey), 'self')?.href !== url) {
136+
} else if (fromCache && getState(store, stateKey).isLoaded && getState(store, stateKey).url === url) {
137+
return Promise.resolve();
138+
} else {
144139
patchState(store, updateState(stateKey, { url: '', isLoading: true }));
145140

146141
const request = requestService.requestWithObservable<TResource>('GET', url);
@@ -163,21 +158,28 @@ export function withHypermediaResource<ResourceName extends string, TResource>(r
163158
}
164159
});
165160
});
166-
} else {
167-
return Promise.resolve();
168161
}
169162
};
170163

164+
const loadFromUrlReactive = signalMethod<string | null>(url => loadFromUrl(url).catch(() => undefined));
165+
166+
function loadFromUrlOverload(url: string | null, fromCache?: boolean): Promise<void>;
167+
function loadFromUrlOverload(url: Signal<string | null>): EffectRef;
168+
function loadFromUrlOverload(url: string | null | Signal<string | null>, fromCache?: boolean): Promise<void> | EffectRef {
169+
if (isSignal(url)) return loadFromUrlReactive(url as Signal<string | null>);
170+
return loadFromUrl(url as string | null, fromCache);
171+
}
172+
173+
const loadFromUrlMethod = loadFromUrlOverload;
174+
171175
const loadFromLinkMethod = async (linkRoot: unknown, linkName: string, params?: Record<string, unknown>): Promise<void> => {
172176
const link = hateoasService.getLink(linkRoot, linkName);
173177
if (link) {
174178
const url = hateoasService.getUrl(linkRoot, linkName, params);
175-
await loadFromUrlMethod(url);
179+
await loadFromUrl(url);
176180
}
177181
};
178182

179-
const connectToUrlMethod = signalMethod<string>(url => loadFromUrlMethod(url));
180-
181183
const reloadMethod = (): Promise<void> => {
182184
if (currentRequestSub) {
183185
currentRequestSub.unsubscribe();
@@ -219,7 +221,6 @@ export function withHypermediaResource<ResourceName extends string, TResource>(r
219221
return {
220222
[loadFromUrlMethodName]: loadFromUrlMethod,
221223
[loadFromLinkMethodName]: loadFromLinkMethod,
222-
[connectToUrlMethodName]: connectToUrlMethod,
223224
[reloadMethodName]: reloadMethod,
224225
[getAsPatchableMethodName]: getAsPatchableMethod
225226
};

0 commit comments

Comments
 (0)