diff --git a/apps/playground/src/app/app.store.ts b/apps/playground/src/app/app.store.ts index c831d50..9456ba4 100644 --- a/apps/playground/src/app/app.store.ts +++ b/apps/playground/src/app/app.store.ts @@ -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' ) ); diff --git a/apps/playground/src/app/core/home/home.store.ts b/apps/playground/src/app/core/home/home.store.ts index 4bf0907..08a01bf 100644 --- a/apps/playground/src/app/core/home/home.store.ts +++ b/apps/playground/src/app/core/home/home.store.ts @@ -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"; @@ -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') ); diff --git a/apps/playground/src/app/flight/flight-create/flight-create.store.ts b/apps/playground/src/app/flight/flight-create/flight-create.store.ts index da1f729..901e640 100644 --- a/apps/playground/src/app/flight/flight-create/flight-create.store.ts +++ b/apps/playground/src/app/flight/flight-create/flight-create.store.ts @@ -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"; @@ -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') ); diff --git a/apps/playground/src/app/flight/flight-edit/flight-edit.store.ts b/apps/playground/src/app/flight/flight-edit/flight-edit.store.ts index e0cef35..ab321f4 100644 --- a/apps/playground/src/app/flight/flight-edit/flight-edit.store.ts +++ b/apps/playground/src/app/flight/flight-edit/flight-edit.store.ts @@ -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 = { @@ -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') ); diff --git a/apps/playground/src/app/flight/flight-search/flight-search.store.ts b/apps/playground/src/app/flight/flight-search/flight-search.store.ts index 8571e94..ff9607d 100644 --- a/apps/playground/src/app/flight/flight-search/flight-search.store.ts +++ b/apps/playground/src/app/flight/flight-search/flight-search.store.ts @@ -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, @@ -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'); } }) ); diff --git a/doc/docs/guide/01-getting-started.md b/doc/docs/guide/01-getting-started.md index 86ebe46..54cf2f0 100644 --- a/doc/docs/guide/01-getting-started.md +++ b/doc/docs/guide/01-getting-started.md @@ -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'; @@ -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') ); ``` @@ -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') ); ``` 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 index 9c599f2..22b7522 100644 --- 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 @@ -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'; @@ -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', () => { 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 df009fa..a97aceb 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 @@ -1,5 +1,5 @@ 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"; @@ -7,6 +7,7 @@ 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' @@ -43,20 +44,16 @@ export function generateExecuteHypermediaActionMethodName(actionName: string) { return actionName; } -export type ConnectHypermediaActionMethod = { - [K in ActionName as `_connect${Capitalize}`]: (linkRoot: Signal, action: string) => void -}; - -export function generateConnectHypermediaActionMethodName(actionName: string) { - return `_connect${actionName.charAt(0).toUpperCase() + actionName.slice(1)}`; -} - export type HypermediaActionMethods = - ExecuteHypermediaActionMethod & ConnectHypermediaActionMethod - -type actionRxInput = { - resource: unknown, - action: string + ExecuteHypermediaActionMethod + +type StoreForActionLinkRoot = StateSignals; + +type ActionLinkRootFn = (store: StoreForActionLinkRoot) => Signal + +type LinkedActionRxInput = { + resource: Resource | undefined, + actionMetaName: string } function getState(store: unknown, stateKey: string): HypermediaActionStateProps { @@ -68,85 +65,80 @@ function updateState(stateKey: string, partialState: Partial ({ [stateKey]: { ...state[stateKey], ...partialState } }); } -export function withHypermediaAction( - actionName: ActionName): SignalStoreFeature< - { - state: object; - computed: Record>; - methods: Record; - props: object; - }, - { +export function withHypermediaAction( + actionName: ActionName, + linkRootFn: ActionLinkRootFn, + actionMetaName: string): SignalStoreFeature< + Input, + Input & { state: HypermediaActionStoreState; - computed: Record>; methods: HypermediaActionMethods; - props: object; } >; -export function withHypermediaAction(actionName: ActionName) { +export function withHypermediaAction( + actionName: ActionName, + linkRootFn: ActionLinkRootFn, + actionMetaName: string) { const stateKey = `${actionName}State`; const executeMethodName = generateExecuteHypermediaActionMethodName(actionName); - const connectMethodName = generateConnectHypermediaActionMethodName(actionName); + let linkRoot: Signal | undefined = undefined; return signalStoreFeature( withState({ [stateKey]: defaultHypermediaActionState }), withMethods((store, requestService = inject(RequestService)) => { - - const hateoasService = inject(HateoasService); - let internalResourceLink: Signal | undefined; - - const rxConnectToResource = rxMethod( - 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> => { + 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> => { - 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, 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); + const linkedActionRxInput = computed(() => ({ resource: linkRoot!(), actionMetaName })); + // Wire up linked object with state + rxMethod( + 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); + } }) ); } diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-collection-action.spec.ts b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-collection-action.spec.ts index 75c93b7..5c1a540 100644 --- a/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-collection-action.spec.ts +++ b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-collection-action.spec.ts @@ -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'; @@ -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', () => { diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-collection-action.ts b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-collection-action.ts index 940f54e..8d3884a 100644 --- a/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-collection-action.ts +++ b/libs/ngrx-hateoas/src/lib/store-features/with-hypermedia-collection-action.ts @@ -1,5 +1,5 @@ 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 { from, map, mergeMap, pipe, tap } from "rxjs"; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { isValidActionVerb } from "../util/is-valid-action-verb"; @@ -58,10 +58,14 @@ export function generateConnectHypermediaCollectionActionMethodName(actionName: export type HypermediaCollectionActionMethods = ExecuteHypermediaCollectionActionMethod & ConnectHypermediaCollectionActionMethod -type ActionRxInput = { +type StoreForCollectionActionLinkRoot = StateSignals; + +type CollectionActionLinkRootFn = (store: StoreForCollectionActionLinkRoot) => Signal + +type LinkedCollectionActionRxInput = { resource: Resource[], idLookup: (resource: Resource) => CollectionKey, - action: string + actionName: string } function getState(store: unknown, stateKey: string): HypermediaCollectionActionStateProps { @@ -94,42 +98,60 @@ function updateItemState(stateKey: string, id: CollectionKey, itemState: Partial }); } -export function withHypermediaCollectionAction( - actionName: ActionName): SignalStoreFeature< - { - state: object; - computed: Record>; - methods: Record; - props: object; - }, - { +function createIdLookupFunction(idKeyName: string) { + return (resource: Resource) => { + const id = resource[idKeyName]; + if(typeof id === 'string' || typeof id === 'number') return id; + else throw new Error("The specified 'idKeyName' must point to a key with a value of type 'string' or 'number'"); + }; +} + +export function withHypermediaCollectionAction( + actionName: ActionName, + linkRootFn: CollectionActionLinkRootFn, + actionMetaName: string, + idKeyName: string): SignalStoreFeature< + Input, + Input & { + state: HypermediaCollectionActionStoreState; + methods: HypermediaCollectionActionMethods; + } + >; +export function withHypermediaCollectionAction( + actionName: ActionName, + linkRootFn: CollectionActionLinkRootFn, + actionMetaName: string): SignalStoreFeature< + Input, + Input & { state: HypermediaCollectionActionStoreState; - computed: Record>; methods: HypermediaCollectionActionMethods; - props: object; } >; -export function withHypermediaCollectionAction(actionName: ActionName) { +export function withHypermediaCollectionAction( + actionName: ActionName, + linkRootFn: CollectionActionLinkRootFn, + actionMetaName: string, + idKeyName = 'id') { const stateKey = `${actionName}State`; const executeMethodName = generateExecuteHypermediaCollectionActionMethodName(actionName); - const connectMethodName = generateConnectHypermediaCollectionActionMethodName(actionName); + let linkRoot: Signal | undefined = undefined; + let internalResourceMap: Signal> | undefined = undefined; return signalStoreFeature( withState({ [stateKey]: defaultHypermediaCollectionActionState }), - withMethods((store, requestService = inject(RequestService)) => { - + withMethods(store => { + const requestService = inject(RequestService); const hateoasService = inject(HateoasService); - let internalResourceMap: Signal> | undefined; - - const rxConnectToResource = rxMethod( + + const rxConnectToResourceMethod = rxMethod( pipe( tap(() => patchState(store, updateState(stateKey, defaultHypermediaCollectionActionState))), mergeMap(input => from(input.resource) .pipe( - map(resource => [resource, hateoasService.getAction(resource, input.action)] satisfies [Resource, ResourceAction | undefined ] as [Resource, ResourceAction | undefined ]), + map(resource => [resource, hateoasService.getAction(resource, input.actionName)] satisfies [Resource, ResourceAction | undefined ] as [Resource, ResourceAction | undefined ]), map(([resource, action]) => { const actionState: HypermediaActionStateProps = { ...defaultHypermediaActionState }; if(action && isValidHref(action.href) && isValidActionVerb(action.method)) { @@ -144,50 +166,69 @@ export function withHypermediaCollectionAction(action ) ); - return { - [executeMethodName]: async (id: CollectionKey): Promise> => { - if(getState(store, stateKey).isAvailable[id] && internalResourceMap) { - const method = getState(store, stateKey).method[id]; - const href = getState(store, stateKey).href[id]; - - if(!method || !href) throw new Error('Action is not available'); - - const body = method !== 'DELETE' ? internalResourceMap()[id] : undefined - - patchState(store, - updateItemState(stateKey, id, { - isExecuting: true, - hasExecutedSuccessfully: false, - hasExecutedWithError: false, - hasError: false, - error: null - })); - - try { - const response = await requestService.request(method, href, body); - patchState(store, updateItemState(stateKey, id, { isExecuting: false, hasExecutedSuccessfully: true } )); - return response; - } catch(e) { - patchState(store, updateItemState(stateKey, id, { isExecuting: false, hasExecutedWithError: true, hasError: true, error: e } )); - throw e; - } - } else { - throw new Error('Action is not available'); - } - }, - [connectMethodName]: (resourceLink: Signal, idKeyName: string, action: string) => { - if(!internalResourceMap) { - const idLookup = (resource: Resource) => { - const id = resource[idKeyName]; - if(typeof id === 'string' || typeof id === 'number') return id; - else throw new Error("The specified 'idKeyName' must point to a key with a value of type 'string' or 'number'"); - }; - internalResourceMap = computed(() => toResourceMap(resourceLink(), idLookup)); - const input = computed(() => ({ resource: resourceLink(), idLookup, action })); - rxConnectToResource(input); - } + const executeMethod = async (id: CollectionKey): Promise> => { + if(getState(store, stateKey).isAvailable[id] && internalResourceMap) { + const method = getState(store, stateKey).method[id]; + const href = getState(store, stateKey).href[id]; + + if(!method || !href) throw new Error('Action is not available'); + + const body = method !== 'DELETE' ? internalResourceMap()[id] : undefined + + patchState(store, + updateItemState(stateKey, id, { + isExecuting: true, + hasExecutedSuccessfully: false, + hasExecutedWithError: false, + hasError: false, + error: null + })); + + try { + const response = await requestService.request(method, href, body); + patchState(store, updateItemState(stateKey, id, { isExecuting: false, hasExecutedSuccessfully: true } )); + return response; + } catch(e) { + patchState(store, updateItemState(stateKey, id, { isExecuting: false, hasExecutedWithError: true, hasError: true, error: e } )); + throw e; + } + } else { + throw new Error('Action is not available'); } + } + + return { + [executeMethodName]: executeMethod, + _rxConnectToResource: rxConnectToResourceMethod }; + }), + withHooks({ + onInit(store, hateoasService = inject(HateoasService)) { + const idLookup = createIdLookupFunction(idKeyName); + linkRoot = linkRootFn(store as unknown as StoreForCollectionActionLinkRoot); + internalResourceMap = computed(() => toResourceMap(linkRoot!(), idLookup)); + const linkedCollectionActionRxInput = computed(() => ({ resource: linkRoot!(), idLookup, actionName: actionMetaName})) + // Wire up linked object with state + rxMethod( + pipe( + tap(() => patchState(store, updateState(stateKey, defaultHypermediaCollectionActionState))), + mergeMap(input => from(input.resource) + .pipe( + map(resource => [resource, hateoasService.getAction(resource, input.actionName)] satisfies [Resource, ResourceAction | undefined ] as [Resource, ResourceAction | undefined ]), + map(([resource, action]) => { + const actionState: HypermediaActionStateProps = { ...defaultHypermediaActionState }; + if(action && isValidHref(action.href) && isValidActionVerb(action.method)) { + actionState.href = action.href; + actionState.method = action.method; + actionState.isAvailable = true; + } + return [resource, actionState] satisfies [Resource, HypermediaActionStateProps] as [Resource, HypermediaActionStateProps]; + }), + tap(([resource, actionState]) => patchState(store, updateItemState(stateKey, input.idLookup(resource), actionState))) + )) + ) + )(linkedCollectionActionRxInput); + } }) ); } 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 71cbbfb..f943228 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 @@ -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 { withLinkedHypermediaResource } from './with-linked-hypermedia-resource'; import { provideHateoas } from '../provide'; @@ -30,12 +30,7 @@ const initialTestModel: TestModel = { const TestStore = signalStore( { providedIn: 'root' }, withHypermediaResource('rootModel', initialRootModel), - withLinkedHypermediaResource('testModel', initialTestModel), - withHooks({ - onInit(store) { - store._connectTestModel(store.rootModel, 'testModel'); - }, - }) + withLinkedHypermediaResource('testModel', initialTestModel, store => store.rootModel, 'testModel') ); describe('withLinkedHypermediaResource', () => { 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 2fb1ab9..cf7eb45 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 @@ -1,11 +1,12 @@ 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, switchMap, tap } from "rxjs"; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { isValidHref } from "../util/is-valid-href"; import { DeepPatchableSignal, toDeepPatchableSignal } from "../util/deep-patchable-signal"; import { RequestService } from "../services/request.service"; import { HateoasService } from "../services/hateoas.service"; +import { Resource } from "../models"; export type LinkedHypermediaResourceStateProps = { url: string, @@ -26,14 +27,6 @@ export type LinkedHypermediaResourceStoreState & LinkedHypermediaResourceState; -export type ConnectLinkedHypermediaResourceMethod = { - [K in ResourceName as `_connect${Capitalize}`]: (linkRoot: Signal, linkName: string) => void -}; - -export function generateConnectLinkedHypermediaResourceMethodName(resourceName: string) { - return `_connect${resourceName.charAt(0).toUpperCase() + resourceName.slice(1)}`; -} - export type ReloadLinkedHypermediaResourceMethod = { [K in ResourceName as `reload${Capitalize}`]: () => Promise }; @@ -51,13 +44,16 @@ export function generateGetAsPatchableLinkedHypermediaResourceMethodName(resourc } export type LinkedHypermediaResourceMethods = - ConnectLinkedHypermediaResourceMethod - & ReloadLinkedHypermediaResourceMethod + ReloadLinkedHypermediaResourceMethod & GetAsPatchableLinkedHypermediaResourceMethod; -type linkedRxInput = { - resource: unknown, - linkName: string +type StoreForResourceLinkRoot = StateSignals; + +type ResourceLinkRootFn = (store: StoreForResourceLinkRoot) => Signal + +type LinkedResourceRxInput = { + resource: Resource | undefined, + linkMetaName: string } function getState(store: unknown, stateKey: string): LinkedHypermediaResourceStateProps { @@ -73,28 +69,28 @@ function updateState(stateKey: string, partialState: Partial ({ [stateKey]: { ...state[stateKey], ...partialState } }); } -export function withLinkedHypermediaResource( - resourceName: ResourceName, initialValue: TResource): SignalStoreFeature< - { - state: object; - computed: Record>; - methods: Record; - props: object; - }, - { +export function withLinkedHypermediaResource( + resourceName: ResourceName, + initialValue: TResource, + linkRootFn: ResourceLinkRootFn, + linkMetaName: string): SignalStoreFeature< + Input, + Input & { state: LinkedHypermediaResourceStoreState; - computed: Record>; methods: LinkedHypermediaResourceMethods; - props: object; } >; -export function withLinkedHypermediaResource(resourceName: ResourceName, initialValue: TResource) { +export function withLinkedHypermediaResource( + resourceName: ResourceName, + initialValue: TResource, + linkRootFn: ResourceLinkRootFn, + linkMetaName: string) { const dataKey = `${resourceName}`; const stateKey = `${resourceName}State`; - const connectMethodName = generateConnectLinkedHypermediaResourceMethodName(resourceName); const reloadMethodName = generateReloadLinkedHypermediaResourceMethodName(resourceName); const getAsPatchableMethodName = generateGetAsPatchableLinkedHypermediaResourceMethodName(resourceName); + let linkRoot: Signal | undefined = undefined; return signalStoreFeature( withState({ @@ -107,58 +103,60 @@ export function withLinkedHypermediaResource { + const patchableSignal = toDeepPatchableSignal(newVal => patchState(store, { [dataKey]: newVal }), (store as unknown as Record>)[dataKey]); - 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, - updateState(stateKey, { url: href, isLoading: true, isAvailable: true } ))), - switchMap(href => requestService.request('GET', href)), - tap(response => response.body ? patchState(store, - updateData(dataKey, response.body), - updateState(stateKey, { isLoading: false, initiallyLoaded: true } )) - : patchState(store, - updateState(stateKey, { isLoading: false, url: undefined, isAvailable: false, initiallyLoaded: false } ))) - ) - ); + const reloadMethod = async (): Promise => { + const currentUrl = getState(store, stateKey).url; + if(currentUrl) { + patchState(store, updateState(stateKey, { isLoading: true } )); - return { - [connectMethodName]: (linkRoot: Signal, linkName: string) => { - const input = computed(() => ({ resource: linkRoot(), linkName })); - rxConnectToLinkRoot(input); - }, - [reloadMethodName]: async (): Promise => { - const currentUrl = getState(store, stateKey).url; - if(currentUrl) { - patchState(store, updateState(stateKey, { isLoading: true } )); - - try { - const response = await requestService.request('GET', currentUrl); - if (!response.body) { - throw new Error(`Response body is empty for URL: ${currentUrl}`); - } - patchState(store, - updateData(dataKey, response.body), - updateState(stateKey, { isLoading: false } )); - } catch(e) { - patchState(store, - updateData(dataKey, initialValue), - updateState(stateKey, { isLoading: false } )); - throw e; + try { + const response = await requestService.request('GET', currentUrl); + if (!response.body) { + throw new Error(`Response body is empty for URL: ${currentUrl}`); } + patchState(store, + updateData(dataKey, response.body), + updateState(stateKey, { isLoading: false } )); + } catch(e) { + patchState(store, + updateData(dataKey, initialValue), + updateState(stateKey, { isLoading: false } )); + throw e; } - }, - [getAsPatchableMethodName]: (): DeepPatchableSignal => { - return patchableSignal; } }; + + const getAsPatchableMethod = (): DeepPatchableSignal => { + return patchableSignal; + } + + return { + [reloadMethodName]: reloadMethod, + [getAsPatchableMethodName]: getAsPatchableMethod + }; + }), + withHooks({ + onInit(store, hateoasService = inject(HateoasService), requestService = inject(RequestService)) { + linkRoot = linkRootFn(store as unknown as StoreForResourceLinkRoot); + const linkedResourceRxInput = computed(() => ({ resource: linkRoot!(), linkMetaName })); + // Wire up linked object with state + rxMethod( + pipe( + map(input => hateoasService.getLink(input.resource, input.linkMetaName)?.href), + filter(href => isValidHref(href)), + map(href => href!), + filter(href => getState(store, stateKey).url !== href), + tap(href => patchState(store, updateState(stateKey, { url: href, isLoading: true, isAvailable: true } ))), + switchMap(href => requestService.request('GET', href)), + tap(response => response.body ? patchState(store, + updateData(dataKey, response.body), + updateState(stateKey, { isLoading: false, initiallyLoaded: true } )) + : patchState(store, + updateState(stateKey, { isLoading: false, url: undefined, isAvailable: false, initiallyLoaded: false } ))) + ) + )(linkedResourceRxInput); + }, }) ); -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 776a091..8e1f025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@angular/platform-browser": "^19.0.6", "@angular/platform-browser-dynamic": "^19.0.6", "@angular/router": "^19.0.6", - "@ngrx/signals": "^19.0.0", + "@ngrx/signals": "^19.2.1", "bootstrap": "5.3.3", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -3986,9 +3986,9 @@ } }, "node_modules/@ngrx/signals": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-19.0.0.tgz", - "integrity": "sha512-Ktgq+wwIVH9HdobLOhrYF6VArIJYZa5lkgajUpSB9QuudpOLja9f7W2RAHQsMUBpQuREgFkTgIEr1vKIzDrGMA==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-19.2.1.tgz", + "integrity": "sha512-Tajd2TVjkxxyFMhnMSWLa5pAWfynjP0VM0B/BCMaLiBrwBBxybxRVENoUDU5tGyiKSax/2tBJC3+sOglmxm27A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" diff --git a/package.json b/package.json index b439b7f..6bf66ff 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@angular/platform-browser": "^19.0.6", "@angular/platform-browser-dynamic": "^19.0.6", "@angular/router": "^19.0.6", - "@ngrx/signals": "^19.0.0", + "@ngrx/signals": "^19.2.1", "bootstrap": "5.3.3", "rxjs": "~7.8.0", "tslib": "^2.3.0",