From f3e2d4879e506d531713aad28f097c99564c38c7 Mon Sep 17 00:00:00 2001
From: Daniel Murrmann <9040811+fancyDevelopment@users.noreply.github.com>
Date: Sat, 28 Sep 2024 22:14:41 +0200
Subject: [PATCH] Final Refactoring of naming changes
---
apps/playground/src/app/app.component.html | 4 +-
apps/playground/src/app/core/core.routes.ts | 2 +-
.../src/app/core/home/home.component.ts | 2 +-
.../src/app/flight/flight.routes.ts | 2 +-
.../playground/src/app/flight/flight.state.ts | 2 +-
libs/ngrx-hateoas/package.json | 2 +-
.../with-hypermedia-action.spec.ts | 119 ++++++++++++++++++
.../store-features/with-hypermedia-action.ts | 1 +
.../with-hypermedia-resource.ts | 1 +
.../with-initial-hypermedia-resource.ts | 5 +-
.../with-linked-hypermedia-resource.spec.ts | 42 +++----
.../with-linked-hypermedia-resource.ts | 69 ++++++----
12 files changed, 201 insertions(+), 50 deletions(-)
create mode 100644 libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-action.spec.ts
diff --git a/apps/playground/src/app/app.component.html b/apps/playground/src/app/app.component.html
index f0b2043..8c7780e 100644
--- a/apps/playground/src/app/app.component.html
+++ b/apps/playground/src/app/app.component.html
@@ -10,14 +10,14 @@
- @if(!appState.userInfo.isAvailable()) {
+ @if(!appState.userInfoState.isAvailable()) {
} @else {
}
diff --git a/apps/playground/src/app/core/core.routes.ts b/apps/playground/src/app/core/core.routes.ts
index c82b9d2..5324ba6 100644
--- a/apps/playground/src/app/core/core.routes.ts
+++ b/apps/playground/src/app/core/core.routes.ts
@@ -11,5 +11,5 @@ export const CORE_ROUTES: Routes = [{
}, {
path: 'home',
component: HomeComponent,
- canActivate: [ () => whenTrue(inject(CoreState).homeVm.initiallyLoaded) ]
+ canActivate: [ () => whenTrue(inject(CoreState).homeVmState.initiallyLoaded) ]
}];
diff --git a/apps/playground/src/app/core/home/home.component.ts b/apps/playground/src/app/core/home/home.component.ts
index 96bb6f9..55f848f 100644
--- a/apps/playground/src/app/core/home/home.component.ts
+++ b/apps/playground/src/app/core/home/home.component.ts
@@ -9,5 +9,5 @@ import { CoreState } from '../core.state';
templateUrl: './home.component.html'
})
export class HomeComponent {
- viewModel = inject(CoreState).homeVm.resource;
+ viewModel = inject(CoreState).homeVm;
}
diff --git a/apps/playground/src/app/flight/flight.routes.ts b/apps/playground/src/app/flight/flight.routes.ts
index 3553534..121a215 100644
--- a/apps/playground/src/app/flight/flight.routes.ts
+++ b/apps/playground/src/app/flight/flight.routes.ts
@@ -26,5 +26,5 @@ export const FLIHGT_ROUTES: Routes = [{
}, {
path: "create",
component: FlightCreateComponent,
- canActivate: [() => whenTrue(inject(FlightState).flightCreateVm.initiallyLoaded)]
+ canActivate: [() => whenTrue(inject(FlightState).flightCreateVmState.initiallyLoaded)]
}];
diff --git a/apps/playground/src/app/flight/flight.state.ts b/apps/playground/src/app/flight/flight.state.ts
index 0509b56..b37a18f 100644
--- a/apps/playground/src/app/flight/flight.state.ts
+++ b/apps/playground/src/app/flight/flight.state.ts
@@ -19,7 +19,7 @@ export const FlightState = signalStore(
store.connectUpdateFlightOperator(store.flightEditVm.flight.operator, 'update');
store.connectUpdateFlightPrice(store.flightEditVm.flight.price, 'update');
store.connectFlightCreateVm(store.flightSearchVm, 'flightCreateVm');
- store.connectCreateFlight(store.flightCreateVm.resource.template, 'create');
+ store.connectCreateFlight(store.flightCreateVm.template, 'create');
}
})
);
diff --git a/libs/ngrx-hateoas/package.json b/libs/ngrx-hateoas/package.json
index 474dbb7..6548f2e 100644
--- a/libs/ngrx-hateoas/package.json
+++ b/libs/ngrx-hateoas/package.json
@@ -1,6 +1,6 @@
{
"name": "@angular-architects/ngrx-hateoas",
- "version": "18.0.0-rc.2",
+ "version": "18.0.0-rc.3",
"peerDependencies": {
"@angular/common": "^18.0.0",
"@angular/core": "^18.0.0",
diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-action.spec.ts b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-action.spec.ts
new file mode 100644
index 0000000..62e0b99
--- /dev/null
+++ b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-action.spec.ts
@@ -0,0 +1,119 @@
+import { TestBed } from '@angular/core/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { signalStore, withHooks } from '@ngrx/signals';
+import { withHypermediaResource } from './with-hypermedia-resource';
+import { provideHateoas } from '../provide';
+import { firstValueFrom, timer } from 'rxjs';
+import { withHypermediaAction } from './with-hypermedia-action';
+
+type TestModel = {
+ name: string,
+ _actions: {
+ doSomething?: {
+ href: string,
+ method: string
+ }
+ }
+}
+
+const initialTestModel: TestModel = {
+ name: 'initial',
+ _actions: {
+ }
+};
+
+const TestStore = signalStore(
+ { providedIn: 'root' },
+ withHypermediaResource('testModel', initialTestModel),
+ withHypermediaAction('doSomething'),
+ withHooks({
+ onInit(store) {
+ store.connectDoSomething(store.testModel, 'doSomething')
+ },
+ })
+);
+
+describe('withHypermediaAction', () => {
+
+ let store: InstanceType
;
+ let httpTestingController: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [ provideHttpClient(), provideHttpClientTesting(), provideHateoas() ]
+ });
+ store = TestBed.inject(TestStore);
+ httpTestingController = TestBed.inject(HttpTestingController);
+ });
+
+ it('sets correct initial resource state', () => {
+ expect(store.doSomethingState.href()).toBe('');
+ expect(store.doSomethingState.method()).toBe('');
+ expect(store.doSomethingState.isAvailable()).toBeFalse();
+ expect(store.doSomethingState.isExecuting()).toBeFalse();
+ expect(store.doSomethingState.hasExecutedSuccessfully()).toBeFalse();
+ expect(store.doSomethingState.hasExecutedWithError()).toBeFalse();
+ expect(store.doSomethingState.hasError()).toBeFalse();
+ expect(store.doSomethingState.error()).toBeNull();
+ });
+
+ it('has correct resource methods', () => {
+ expect(store.doSomething).toBeDefined();
+ expect(store.connectDoSomething).toBeDefined();
+ });
+
+ it('does not execute action not available', async () => {
+ await store.doSomething();
+ httpTestingController.verify();
+ });
+
+ it('executes a successfull action after it is available', async () => {
+
+ const testModel = store.getTestModelAsPatchable();
+ testModel.name.patch('foobar');
+ testModel._actions.patch({ doSomething: { href: '/api/do-something', method: 'PUT' } });
+
+ await firstValueFrom(timer(0));
+
+ expect(store.doSomethingState.href()).toBe('/api/do-something');
+ expect(store.doSomethingState.method()).toBe('PUT');
+ expect(store.doSomethingState.isAvailable()).toBeTrue();
+ expect(store.doSomethingState.isExecuting()).toBeFalse();
+ expect(store.doSomethingState.hasExecutedSuccessfully()).toBeFalse();
+ expect(store.doSomethingState.hasExecutedWithError()).toBeFalse();
+ expect(store.doSomethingState.hasError()).toBeFalse();
+ expect(store.doSomethingState.error()).toBeNull();
+
+ const doSomethingPromise = store.doSomething();
+
+ expect(store.doSomethingState.href()).toBe('/api/do-something');
+ expect(store.doSomethingState.method()).toBe('PUT');
+ expect(store.doSomethingState.isAvailable()).toBeTrue();
+ expect(store.doSomethingState.isExecuting()).toBeTrue();
+ expect(store.doSomethingState.hasExecutedSuccessfully()).toBeFalse();
+ expect(store.doSomethingState.hasExecutedWithError()).toBeFalse();
+ expect(store.doSomethingState.hasError()).toBeFalse();
+ expect(store.doSomethingState.error()).toBeNull();
+
+ const actionRequest = httpTestingController.expectOne('/api/do-something');
+ httpTestingController.verify();
+
+ expect(actionRequest.request.method).toBe('PUT');
+ expect(actionRequest.request.body.name).toBe('foobar');
+
+ actionRequest.flush(204);
+
+ await doSomethingPromise;
+
+ expect(store.doSomethingState.href()).toBe('/api/do-something');
+ expect(store.doSomethingState.method()).toBe('PUT');
+ expect(store.doSomethingState.isAvailable()).toBeTrue();
+ expect(store.doSomethingState.isExecuting()).toBeFalse();
+ expect(store.doSomethingState.hasExecutedSuccessfully()).toBeTrue();
+ expect(store.doSomethingState.hasExecutedWithError()).toBeFalse();
+ expect(store.doSomethingState.hasError()).toBeFalse();
+ expect(store.doSomethingState.error()).toBeNull();
+ });
+
+});
diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-action.ts b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-action.ts
index c9037ee..c61d007 100644
--- a/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-action.ts
+++ b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-action.ts
@@ -52,6 +52,7 @@ function getState(store: unknown, stateKey: string): HypermediaActionStateProps
}
function updateState(stateKey: string, partialState: Partial) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
return (state: any) => ({ [stateKey]: { ...state[stateKey], ...partialState } });
}
diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-resource.ts b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-resource.ts
index 5fd05c8..a8c624e 100644
--- a/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-resource.ts
+++ b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-resource.ts
@@ -73,6 +73,7 @@ function updateData(dataKey: string, data: TResource) {
}
function updateState(stateKey: string, partialState: Partial) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
return (state: any) => ({ [stateKey]: { ...state[stateKey], ...partialState } });
}
diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-initial-hypermedia-resource.ts b/libs/ngrx-hateoas/src/lib/store-features/with-initial-hypermedia-resource.ts
index 391fa5b..3b3dd2f 100644
--- a/libs/ngrx-hateoas/src/lib/store-features/with-initial-hypermedia-resource.ts
+++ b/libs/ngrx-hateoas/src/lib/store-features/with-initial-hypermedia-resource.ts
@@ -5,7 +5,10 @@ import { Signal } from "@angular/core";
export function withInitialHypermediaResource(
resourceName: ResourceName, initialValue: TResource, url: string | (() => string)): SignalStoreFeature<
{
- state: object; computed: Record>; methods: Record },
+ state: object;
+ computed: Record>;
+ methods: Record
+ },
{
state: HypermediaResourceStoreState;
computed: Record>;
diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.spec.ts b/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.spec.ts
index b431f23..4fe4b43 100644
--- a/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.spec.ts
+++ b/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.spec.ts
@@ -52,11 +52,11 @@ describe('withLinkedHypermediaResource', () => {
});
it('sets correct initial resource state', () => {
- expect(store.testModel.url()).toBe('');
- expect(store.testModel.initiallyLoaded()).toBeFalse();
- expect(store.testModel.isAvailable()).toBeFalse();
- expect(store.testModel.isLoading()).toBeFalse();
- expect(store.testModel.resource()).toBe(initialTestModel);
+ expect(store.testModelState.url()).toBe('');
+ expect(store.testModelState.initiallyLoaded()).toBeFalse();
+ expect(store.testModelState.isAvailable()).toBeFalse();
+ expect(store.testModelState.isLoading()).toBeFalse();
+ expect(store.testModel()).toBe(initialTestModel);
});
it('has correct resource methods', () => {
@@ -83,20 +83,20 @@ describe('withLinkedHypermediaResource', () => {
await loadRootModel;
- expect(store.testModel.url()).toBe('/api/test-model');
- expect(store.testModel.initiallyLoaded()).toBeFalse();
- expect(store.testModel.isLoading()).toBeTrue();
- expect(store.testModel.isAvailable()).toBeTrue();
+ expect(store.testModelState.url()).toBe('/api/test-model');
+ expect(store.testModelState.initiallyLoaded()).toBeFalse();
+ expect(store.testModelState.isLoading()).toBeTrue();
+ expect(store.testModelState.isAvailable()).toBeTrue();
httpTestingController.expectOne('/api/test-model').flush(testModelFromLink);
httpTestingController.verify();
await firstValueFrom(timer(0));
- expect(store.testModel.url()).toBe('/api/test-model');
- expect(store.testModel.initiallyLoaded()).toBeTrue();
- expect(store.testModel.isLoading()).toBeFalse();
- expect(store.testModel.isAvailable()).toBeTrue();
+ expect(store.testModelState.url()).toBe('/api/test-model');
+ expect(store.testModelState.initiallyLoaded()).toBeTrue();
+ expect(store.testModelState.isLoading()).toBeFalse();
+ expect(store.testModelState.isAvailable()).toBeTrue();
loadRootModel = store.reloadRootModel();
@@ -105,10 +105,10 @@ describe('withLinkedHypermediaResource', () => {
await loadRootModel;
- expect(store.testModel.url()).toBe('/api/test-model-changed');
- expect(store.testModel.initiallyLoaded()).toBeTrue();
- expect(store.testModel.isLoading()).toBeTrue();
- expect(store.testModel.isAvailable()).toBeTrue();
+ expect(store.testModelState.url()).toBe('/api/test-model-changed');
+ expect(store.testModelState.initiallyLoaded()).toBeTrue();
+ expect(store.testModelState.isLoading()).toBeTrue();
+ expect(store.testModelState.isAvailable()).toBeTrue();
httpTestingController.expectOne('/api/test-model-changed');
httpTestingController.verify();
@@ -138,10 +138,10 @@ describe('withLinkedHypermediaResource', () => {
await firstValueFrom(timer(0));
- expect(store.testModel.url()).toBe('/api/test-model');
- expect(store.testModel.initiallyLoaded()).toBeTrue();
- expect(store.testModel.isLoading()).toBeFalse();
- expect(store.testModel.isAvailable()).toBeTrue();
+ expect(store.testModelState.url()).toBe('/api/test-model');
+ expect(store.testModelState.initiallyLoaded()).toBeTrue();
+ expect(store.testModelState.isLoading()).toBeFalse();
+ expect(store.testModelState.isAvailable()).toBeTrue();
loadRootModel = store.reloadRootModel();
diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.ts b/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.ts
index f73388b..d315558 100644
--- a/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.ts
+++ b/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.ts
@@ -7,19 +7,25 @@ import { DeepPatchableSignal, toDeepPatchableSignal } from "../util/deep-patchab
import { RequestService } from "../services/request.service";
import { HateoasService } from "../services/hateoas.service";
-export type LinkedHypermediaResourceProps = {
+export type LinkedHypermediaResourceStateProps = {
url: string,
isLoading: boolean,
isAvailable: boolean,
- initiallyLoaded: boolean,
- resource: TResource
+ initiallyLoaded: boolean
}
-export type LinkedHypermediaResourceState =
-{
- [K in ResourceName]: LinkedHypermediaResourceProps
+export type LinkedHypermediaResourceData = {
+ [K in `${ResourceName}`]: TResource
};
+export type LinkedHypermediaResourceState = {
+ [K in `${ResourceName}State`]: LinkedHypermediaResourceStateProps
+};
+
+export type LinkedHypermediaResourceStoreState =
+ LinkedHypermediaResourceData
+ & LinkedHypermediaResourceState;
+
export type ConnectLinkedHypermediaResourceMethod = {
[K in ResourceName as `connect${Capitalize}`]: (linkRoot: Signal, linkName: string) => void
};
@@ -54,22 +60,36 @@ type linkedRxInput = {
linkName: string
}
-function getState(store: unknown, stateKey: string): LinkedHypermediaResourceProps {
- return (store as Record>>)[stateKey]()
+function getState(store: unknown, stateKey: string): LinkedHypermediaResourceStateProps {
+ return (store as Record>)[stateKey]()
+}
+
+function updateData(dataKey: string, data: TResource) {
+ return () => ({ [dataKey]: data});
+}
+
+function updateState(stateKey: string, partialState: Partial) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (state: any) => ({ [stateKey]: { ...state[stateKey], ...partialState } });
}
export function withLinkedHypermediaResource(
resourceName: ResourceName, initialValue: TResource): SignalStoreFeature<
- { state: object; computed: Record>; methods: Record },
+ {
+ state: object;
+ computed: Record>;
+ methods: Record
+ },
{
- state: LinkedHypermediaResourceState;
+ state: LinkedHypermediaResourceStoreState;
computed: Record>;
methods: LinkedHypermediaResourceMethods;
}
>;
export function withLinkedHypermediaResource(resourceName: ResourceName, initialValue: TResource) {
- const stateKey = `${resourceName}`;
+ const dataKey = `${resourceName}`;
+ const stateKey = `${resourceName}State`;
const connectMehtodName = generateConnectLinkedHypermediaResourceMethodName(resourceName);
const reloadMethodName = generateReloadLinkedHypermediaResourceMethodName(resourceName);
const getAsPatchableMethodName = generateGetAsPatchableLinkedHypermediaResourceMethodName(resourceName);
@@ -80,28 +100,31 @@ export function withLinkedHypermediaResource {
const hateoasService = inject(HateoasService);
+ const patchableSignal = toDeepPatchableSignal(newVal => patchState(store, { [dataKey]: newVal }), (store as Record>)[dataKey]);
+
const rxConnectToLinkRoot = rxMethod(
pipe(
map(input => hateoasService.getLink(input.resource, input.linkName)?.href),
filter(href => isValidHref(href)),
map(href => href!),
filter(href => getState(store, stateKey).url !== href),
- tap(href => patchState(store, { [stateKey]: { ...getState(store, stateKey), url: href, isLoading: true, isAvailable: true } })),
+ tap(href => patchState(store,
+ updateState(stateKey, { url: href, isLoading: true, isAvailable: true } ))),
switchMap(href => requestService.request('GET', href)),
- tap(resource => patchState(store, { [stateKey]: { ...getState(store, stateKey), resource, isLoading: false, initiallyLoaded: true } }))
+ tap(resource => patchState(store,
+ updateData(dataKey, resource),
+ updateState(stateKey, { isLoading: false, initiallyLoaded: true } )))
)
);
- const patchableSignal = toDeepPatchableSignal(newVal => patchState(store, { [stateKey]: { ...getState(store, stateKey), resource: newVal } }), (store as Record>>)[stateKey].resource);
-
return {
[connectMehtodName]: (linkRoot: Signal, linkName: string) => {
const input = computed(() => ({ resource: linkRoot(), linkName }));
@@ -110,13 +133,17 @@ export function withLinkedHypermediaResource => {
const currentUrl = getState(store, stateKey).url;
if(currentUrl) {
- patchState(store, { [stateKey]: { ...getState(store, stateKey), isLoading: true } });
+ patchState(store, updateState(stateKey, { isLoading: true } ));
try {
const resource = await requestService.request('GET', currentUrl);
- patchState(store, { [stateKey]: { ...getState(store, stateKey), isLoading: false, resource } });
+ patchState(store,
+ updateData(dataKey, resource),
+ updateState(stateKey, { isLoading: false } ));
} catch(e) {
- patchState(store, { [stateKey]: { ...getState(store, stateKey), isLoading: false, resource: initialValue } });
+ patchState(store,
+ updateData(dataKey, initialValue),
+ updateState(stateKey, { isLoading: false } ));
throw e;
}
}