Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions apps/playground/src/app/app.store.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { initialDynamicResource, withInitialHypermediaResource, withLinkedHypermediaResource } from "@angular-architects/ngrx-hateoas";
import { signalStore, withHooks } from "@ngrx/signals";
import { signalStore } from "@ngrx/signals";

export const AppStore = signalStore(
{ providedIn: 'root' },
withInitialHypermediaResource('rootApi', initialDynamicResource, 'http://localhost:5100/api'),
withLinkedHypermediaResource('userInfo', { name: '', preferred_username: '' }),
withHooks({
onInit(store) {
store._connectUserInfo(store.rootApi, 'userinfo')
}
})
withLinkedHypermediaResource('userInfo', { name: '', preferred_username: '' }, store => store.rootApi, 'userinfo' )
);
9 changes: 2 additions & 7 deletions apps/playground/src/app/core/home/home.store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { withLinkedHypermediaResource } from "@angular-architects/ngrx-hateoas";
import { inject } from "@angular/core";
import { signalStore, withHooks } from "@ngrx/signals";
import { signalStore } from "@ngrx/signals";
import { FlightManagementSummary, FlightShoppingSummary } from "../core.entities";
import { AppStore } from "../../app.store";

Expand All @@ -24,10 +24,5 @@ export const initialHomeVm: HomeVm = {

export const HomeStore = signalStore(
{ providedIn: 'root' },
withLinkedHypermediaResource('homeVm', initialHomeVm),
withHooks({
onInit(store) {
store._connectHomeVm(inject(AppStore).rootApi, 'homeVm')
}
})
withLinkedHypermediaResource('homeVm', initialHomeVm, () => inject(AppStore).rootApi, 'homeVm')
);
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { withHypermediaAction, withLinkedHypermediaResource } from "@angular-architects/ngrx-hateoas";
import { signalStore, withHooks } from "@ngrx/signals";
import { signalStore } from "@ngrx/signals";
import { FlightConnection, FlightTimes, FlightOperator, Aircraft, initialFlightConnection, initialFlightTimes, initialFlightOperator } from "../flight.entities";
import { FlightSearchStore } from "../flight-search/flight-search.store";
import { inject } from "@angular/core";
Expand All @@ -24,12 +24,6 @@ export const initialFlightCreateVm: FlightCreateVm = {

export const FlightCreateStore = signalStore(
{ providedIn: 'root' },
withLinkedHypermediaResource('flightCreateVm', initialFlightCreateVm),
withHypermediaAction('createFlight'),
withHooks({
onInit(store) {
store._connectFlightCreateVm(inject(FlightSearchStore).flightSearchVm, 'flightCreateVm');
store._connectCreateFlight(store.flightCreateVm.template, 'create');
}
})
withLinkedHypermediaResource('flightCreateVm', initialFlightCreateVm, () => inject(FlightSearchStore).flightSearchVm, 'flightCreateVm'),
withHypermediaAction('createFlight', store => store.flightCreateVm.template, 'create')
);
18 changes: 5 additions & 13 deletions apps/playground/src/app/flight/flight-edit/flight-edit.store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { withHypermediaResource, withHypermediaAction } from "@angular-architects/ngrx-hateoas";
import { signalStore, withHooks } from "@ngrx/signals";
import { signalStore } from "@ngrx/signals";
import { Aircraft, Flight, initialFlight } from "../flight.entities";

export type FlightEditVm = {
Expand All @@ -15,16 +15,8 @@ export const initialFlightEditVm: FlightEditVm = {
export const FlightEditStore = signalStore(
{ providedIn: 'root' },
withHypermediaResource('flightEditVm', initialFlightEditVm),
withHypermediaAction('updateFlightConnection'),
withHypermediaAction('updateFlightTimes'),
withHypermediaAction('updateFlightOperator'),
withHypermediaAction('updateFlightPrice'),
withHooks({
onInit(store) {
store._connectUpdateFlightConnection(store.flightEditVm.flight.connection, 'update');
store._connectUpdateFlightTimes(store.flightEditVm.flight.times, 'update');
store._connectUpdateFlightOperator(store.flightEditVm.flight.operator, 'update');
store._connectUpdateFlightPrice(store.flightEditVm.flight.price, 'update');
}
})
withHypermediaAction('updateFlightConnection', store => store.flightEditVm.flight.connection, 'update'),
withHypermediaAction('updateFlightTimes', store => store.flightEditVm.flight.times, 'update'),
withHypermediaAction('updateFlightOperator', store => store.flightEditVm.flight.operator, 'update'),
withHypermediaAction('updateFlightPrice', store => store.flightEditVm.flight.price, 'update')
);
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { withHypermediaResource, withHypermediaCollectionAction } from "@angular-architects/ngrx-hateoas";
import { signalStore, withHooks } from "@ngrx/signals";
import { Flight } from "../flight.entities";
import { inject } from "@angular/core";
import { AppStore } from "../../app.store";

export type FlightSearchVm = {
from: string,
Expand All @@ -17,10 +19,10 @@ const initialFlightSearchVm: FlightSearchVm = {
export const FlightSearchStore = signalStore(
{ providedIn: 'root' },
withHypermediaResource('flightSearchVm', initialFlightSearchVm),
withHypermediaCollectionAction('deleteFlight'),
withHypermediaCollectionAction('deleteFlight', store => store.flightSearchVm.flights, 'delete'),
withHooks({
onInit(store) {
store._connectDeleteFlight(store.flightSearchVm.flights, 'id', 'delete');
store.loadFlightSearchVmFromLink(inject(AppStore).rootApi(), 'flightSearchVm');
}
})
);
23 changes: 5 additions & 18 deletions doc/docs/guide/01-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ To mutate the state of the flight within the signal store you have the following
:::

## Send changed state Back to the Server
To be able to send the changed flight connection back to the server we have to configure the signal store to offer an action for this. To do this we use the `withHypermediaAction` feature from **ngrx-hateoas**. The hypermedia action needs to be configured with an object in the state and an action name to monitor. If you look into the example JSON at the beginning you see the required metadata is directly placed into the `connection` key. And the name of the action is `update`. This two information needs to be provided to the action. This is done with the help of a connect method within the `onInit` hook.
To be able to send the changed flight connection back to the server we have to configure the signal store to offer an action for this. To do this we use the `withHypermediaAction` feature from **ngrx-hateoas**. The hypermedia action needs to be configured with an object in the state and an action name to monitor. If you look into the example JSON at the beginning you see the required metadata is directly placed into the `connection` key. And the name of the action is `update`. This two information needs to be provided to the action feature.

```ts
import { signalStore, withHooks } from '@ngrx/signals';
Expand All @@ -223,13 +223,7 @@ export const FlightEditStore = signalStore(
{ providedIn: 'root' },
withHypermediaResource('flightModel', initialFlight),
// Add this feature
withHypermediaAction('updateFlightConnection'),
// Connect the action with the metadata
withHooks({
onInit(store) {
store._connectUpdateFlightConnection(store.flightModel.connection, 'update');
}
})
withHypermediaAction('updateFlightConnection', store => store.flightModel.connection, 'update')
);
```

Expand Down Expand Up @@ -267,16 +261,9 @@ import { withHypermediaResource, withHypermediaAction } from '@angular-architect
export const FlightEditStore = signalStore(
{ providedIn: 'root' },
withHypermediaResource('flightModel', initialFlight),
withHypermediaAction('updateFlightConnection'),
withHypermediaAction('updateFlightTimes'),
withHypermediaAction('deleteFlight'),
withHooks({
onInit(store) {
store._connectUpdateFlightConnection(store.flightModel.connection, 'update');
store._connectUpdateFlightTimes(store.flightModel.times, 'update');
store._connectDeleteFlight(store.flightModel, 'delete');
}
})
withHypermediaAction('updateFlightConnection', store => store.flightModel.connection, 'update'),
withHypermediaAction('updateFlightTimes', store => store.flightModel.times, 'update'),
withHypermediaAction('deleteFlight', store => store.flightModel, 'delete')
);
```

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 { signalStore } from '@ngrx/signals';
import { withHypermediaResource } from './with-hypermedia-resource';
import { provideHateoas } from '../provide';
import { firstValueFrom, timer } from 'rxjs';
Expand All @@ -26,12 +26,7 @@ const initialTestModel: TestModel = {
const TestStore = signalStore(
{ providedIn: 'root' },
withHypermediaResource('testModel', initialTestModel),
withHypermediaAction('doSomething'),
withHooks({
onInit(store) {
store._connectDoSomething(store.testModel, 'doSomething')
},
})
withHypermediaAction('doSomething', store => store.testModel, 'doSomething')
);

describe('withHypermediaAction', () => {
Expand Down
146 changes: 69 additions & 77 deletions libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-action.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Signal, computed, inject } from "@angular/core";
import { SignalStoreFeature, patchState, signalStoreFeature, withMethods, withState } from "@ngrx/signals";
import { SignalStoreFeature, SignalStoreFeatureResult, StateSignals, patchState, signalStoreFeature, withHooks, withMethods, withState } from "@ngrx/signals";
import { filter, map, pipe, tap } from "rxjs";
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { isValidActionVerb } from "../util/is-valid-action-verb";
import { isValidHref } from "../util/is-valid-href";
import { RequestService } from "../services/request.service";
import { HateoasService } from "../services/hateoas.service";
import { HttpResponse } from "@angular/common/http";
import { Resource } from "../models";

export type HypermediaActionStateProps = {
method: '' | 'PUT' | 'POST' | 'DELETE'
Expand Down Expand Up @@ -43,20 +44,16 @@ export function generateExecuteHypermediaActionMethodName(actionName: string) {
return actionName;
}

export type ConnectHypermediaActionMethod<ActionName extends string> = {
[K in ActionName as `_connect${Capitalize<ActionName>}`]: (linkRoot: Signal<unknown>, action: string) => void
};

export function generateConnectHypermediaActionMethodName(actionName: string) {
return `_connect${actionName.charAt(0).toUpperCase() + actionName.slice(1)}`;
}

export type HypermediaActionMethods<ActionName extends string> =
ExecuteHypermediaActionMethod<ActionName> & ConnectHypermediaActionMethod<ActionName>

type actionRxInput = {
resource: unknown,
action: string
ExecuteHypermediaActionMethod<ActionName>

type StoreForActionLinkRoot<Input extends SignalStoreFeatureResult> = StateSignals<Input['state']>;

type ActionLinkRootFn<T extends SignalStoreFeatureResult> = (store: StoreForActionLinkRoot<T>) => Signal<Resource | undefined>

type LinkedActionRxInput = {
resource: Resource | undefined,
actionMetaName: string
}

function getState(store: unknown, stateKey: string): HypermediaActionStateProps {
Expand All @@ -68,85 +65,80 @@ function updateState(stateKey: string, partialState: Partial<HypermediaActionSta
return (state: any) => ({ [stateKey]: { ...state[stateKey], ...partialState } });
}

export function withHypermediaAction<ActionName extends string>(
actionName: ActionName): SignalStoreFeature<
{
state: object;
computed: Record<string, Signal<unknown>>;
methods: Record<string, Function>;
props: object;
},
{
export function withHypermediaAction<ActionName extends string, Input extends SignalStoreFeatureResult>(
actionName: ActionName,
linkRootFn: ActionLinkRootFn<Input>,
actionMetaName: string): SignalStoreFeature<
Input,
Input & {
state: HypermediaActionStoreState<ActionName>;
computed: Record<string, Signal<unknown>>;
methods: HypermediaActionMethods<ActionName>;
props: object;
}
>;
export function withHypermediaAction<ActionName extends string>(actionName: ActionName) {
export function withHypermediaAction<ActionName extends string, Input extends SignalStoreFeatureResult>(
actionName: ActionName,
linkRootFn: ActionLinkRootFn<Input>,
actionMetaName: string) {

const stateKey = `${actionName}State`;
const executeMethodName = generateExecuteHypermediaActionMethodName(actionName);
const connectMethodName = generateConnectHypermediaActionMethodName(actionName);
let linkRoot: Signal<Resource | undefined> | undefined = undefined;

return signalStoreFeature(
withState({
[stateKey]: defaultHypermediaActionState
}),
withMethods((store, requestService = inject(RequestService)) => {

const hateoasService = inject(HateoasService);
let internalResourceLink: Signal<unknown> | undefined;

const rxConnectToResource = rxMethod<actionRxInput>(
pipe(
tap(() => patchState(store, updateState(stateKey, { href: '', method: '', isAvailable: false } ))),
map(input => hateoasService.getAction(input.resource, input.action)),
filter(action => isValidHref(action?.href) && isValidActionVerb(action?.method)),
map(action => action!),
tap(action => patchState(store, updateState(stateKey, { href: action.href, method: action.method, isAvailable: true } )))
)
);
const executeMethod = async (): Promise<HttpResponse<unknown>> => {
if(getState(store, stateKey).isAvailable && linkRoot) {
const method = getState(store, stateKey).method;
const href = getState(store, stateKey).href;

if(!method || !href) throw new Error('Action is not available');

const body = method !== 'DELETE' ? linkRoot() : undefined

patchState(store,
updateState(stateKey, {
isExecuting: true,
hasExecutedSuccessfully: false,
hasExecutedWithError: false,
hasError: false,
error: null
}));

try {
const response = await requestService.request(method, href, body);
patchState(store, updateState(stateKey, { isExecuting: false, hasExecutedSuccessfully: true } ));
return response;
} catch(e) {
patchState(store, updateState(stateKey, { isExecuting: false, hasExecutedWithError: true, hasError: true, error: e } ));
throw e;
}
} else {
throw new Error('Action is not available');
}
};

return {
[executeMethodName]: async (): Promise<HttpResponse<unknown>> => {
if(getState(store, stateKey).isAvailable && internalResourceLink) {
const method = getState(store, stateKey).method;
const href = getState(store, stateKey).href;

if(!method || !href) throw new Error('Action is not available');

const body = method !== 'DELETE' ? internalResourceLink() : undefined

patchState(store,
updateState(stateKey, {
isExecuting: true,
hasExecutedSuccessfully: false,
hasExecutedWithError: false,
hasError: false,
error: null
}));

try {
const response = await requestService.request(method, href, body);
patchState(store, updateState(stateKey, { isExecuting: false, hasExecutedSuccessfully: true } ));
return response;
} catch(e) {
patchState(store, updateState(stateKey, { isExecuting: false, hasExecutedWithError: true, hasError: true, error: e } ));
throw e;
}
} else {
throw new Error('Action is not available');
}
},
[connectMethodName]: (resourceLink: Signal<unknown>, action: string) => {
if(!internalResourceLink) {
internalResourceLink = resourceLink;
const input = computed(() => ({ resource: resourceLink(), action }));
rxConnectToResource(input);
}
}
[executeMethodName]: executeMethod,
};
}),
withHooks({
onInit(store, hateoasService = inject(HateoasService)) {
linkRoot = linkRootFn(store as unknown as StoreForActionLinkRoot<Input>);
const linkedActionRxInput = computed(() => ({ resource: linkRoot!(), actionMetaName }));
// Wire up linked object with state
rxMethod<LinkedActionRxInput>(
pipe(
tap(() => patchState(store, updateState(stateKey, { href: '', method: '', isAvailable: false } ))),
map(input => hateoasService.getAction(input.resource, input.actionMetaName)),
filter(action => isValidHref(action?.href) && isValidActionVerb(action?.method)),
map(action => action!),
tap(action => patchState(store, updateState(stateKey, { href: action.href, method: action.method, isAvailable: true } )))
)
)(linkedActionRxInput);
}
})
);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 { signalStore } from '@ngrx/signals';
import { withHypermediaResource } from './with-hypermedia-resource';
import { provideHateoas } from '../provide';
import { firstValueFrom, timer } from 'rxjs';
Expand Down Expand Up @@ -40,12 +40,7 @@ const initialTestModel: TestModel = {
const TestStore = signalStore(
{ providedIn: 'root' },
withHypermediaResource('testModel', initialTestModel),
withHypermediaCollectionAction('doSomething'),
withHooks({
onInit(store) {
store._connectDoSomething(store.testModel.items, 'name', 'doSomething');
},
})
withHypermediaCollectionAction('doSomething', store => store.testModel.items, 'doSomething', 'name')
);

describe('withHypermediaCollectionAction', () => {
Expand Down
Loading