Skip to content

Commit 10670da

Browse files
Merge pull request #13 from angular-architects/feature/actions-in-collections
Actions in Collections
2 parents bf720aa + e971b15 commit 10670da

File tree

11 files changed

+235
-28
lines changed

11 files changed

+235
-28
lines changed

apps/playground/server.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ server.get('/views/flightSearchVm', (req, res) => {
5555

5656
flights.forEach(flight => {
5757
flight._links = { flightEditVm: { href: 'http://localhost:5100/views/flightEditVm/' + flight.id } };
58+
flight._actions = { delete: { method: 'delete', href: 'http://localhost:5100/flights/' + flight.id } };
5859
});
5960

6061
const result = {
@@ -154,6 +155,13 @@ server.put('/flights/:flightId/price', (req, res) => {
154155
res.sendStatus(204);
155156
});
156157

158+
server.delete('/flights/:flightId', (req, res) => {
159+
const flightId = req.params.flightId;
160+
const index = db.flights.findIndex(f => f.id == flightId);
161+
db.flights.splice(index, 1);
162+
res.sendStatus(204);
163+
});
164+
157165
server.listen(5100, () => {
158166
console.log('JSON Server is running');
159167
});

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,16 @@
3636
<span class="lead pb-3">Search Results</span>
3737
</div>
3838

39-
@if(!viewModel()['flights']) {
39+
@if(!viewModel.flights()) {
4040
<div class="row">
4141
<p>Search for flights to show something here!</p>
4242
</div>
4343
} @else {
4444
<div class="row row-cols-1 row-cols-md-2 g-4">
4545

46-
@for(flight of $any(viewModel())['flights']; track flight.id) {
46+
@for(flight of viewModel.flights(); track flight.id) {
4747
<div class="col">
48-
<app-flight-summary-card [flight]="flight">
48+
<app-flight-summary-card [flight]="flight" (delete)="onDelete(flight.id)">
4949
@if(flight | hasLink:'flightEditVm') {
5050
<a class="btn btn-primary" [routerLink]="['/flight/edit', (flight | getLink:'flightEditVm')?.href]">Edit</a>
5151
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, inject, signal } from '@angular/core';
1+
import { Component, inject } from '@angular/core';
22
import { FormsModule } from '@angular/forms';
33
import { Router, RouterLink } from '@angular/router';
44
import { GetLinkPipe, HasLinkPipe, HateoasService } from '@angular-architects/ngrx-hateoas';
@@ -25,4 +25,9 @@ export class FlightSearchComponent {
2525
url = url + '?from=' + (this.from() ?? '') + '&to=' + (this.to() ?? '');
2626
this.router.navigate(['/flight/search', url]);
2727
}
28+
29+
async onDelete(flightId: number) {
30+
await this.flightState.deleteFlight(flightId);
31+
this.flightState.reloadFlightSearchVm();
32+
}
2833
}

apps/playground/src/app/flight/flight.state.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { initialDynamicResource, withHypermediaResource, withHypermediaAction, withLinkedHypermediaResource } from "@angular-architects/ngrx-hateoas";
1+
import { withHypermediaResource, withHypermediaAction, withLinkedHypermediaResource, withHypermediaCollectionAction } from "@angular-architects/ngrx-hateoas";
22
import { signalStore, withHooks } from "@ngrx/signals";
3-
import { initialFlightCreateVm, initialFlightEditVm } from "./flight.entities";
3+
import { Flight, initialFlightCreateVm, initialFlightEditVm } from "./flight.entities";
44

55
export const FlightState = signalStore(
66
{ providedIn: 'root' },
7-
withHypermediaResource('flightSearchVm', initialDynamicResource),
7+
withHypermediaResource('flightSearchVm', { flights: [], from: '', to: '' } as { flights: Flight[], from: string, to: string }),
8+
withHypermediaCollectionAction('deleteFlight'),
89
withHypermediaResource('flightEditVm', initialFlightEditVm),
910
withHypermediaAction('updateFlightConnection'),
1011
withHypermediaAction('updateFlightTimes'),
@@ -14,6 +15,7 @@ export const FlightState = signalStore(
1415
withHypermediaAction('createFlight'),
1516
withHooks({
1617
onInit(store) {
18+
store._connectDeleteFlight(store.flightSearchVm.flights, 'id', 'delete');
1719
store._connectUpdateFlightConnection(store.flightEditVm.flight.connection, 'update');
1820
store._connectUpdateFlightTimes(store.flightEditVm.flight.times, 'update');
1921
store._connectUpdateFlightOperator(store.flightEditVm.flight.operator, 'update');

apps/playground/src/app/flight/shared/flight-summary-card/flight-summary-card.component.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ <h5 class="card-title">{{flight.connection.from}} - {{flight.connection.to}}</h5
1010
</div>
1111
<div class="card-footer d-flex justify-content-between align-items-center">
1212
@if(flight | hasLink:'flightEditVm') {
13-
<a class="btn btn-primary" [routerLink]="['/flight/edit', (flight | getLink:'flightEditVm')?.href]">Edit</a>
13+
<button class="btn btn-primary" [routerLink]="['/flight/edit', (flight | getLink:'flightEditVm')?.href]">Edit</button>
14+
<button class="btn btn-secondary" (click)="delete.emit()">Delete</button>
1415
}
1516
</div>
1617
</div>

apps/playground/src/app/flight/shared/flight-summary-card/flight-summary-card.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, input } from '@angular/core';
1+
import { Component, input, output } from '@angular/core';
22
import { CurrencyPipe, DatePipe } from '@angular/common';
33
import { RouterLink } from '@angular/router';
44
import { GetLinkPipe, HasLinkPipe } from '@angular-architects/ngrx-hateoas';
@@ -12,4 +12,5 @@ import { Flight } from '../../flight.entities';
1212
})
1313
export class FlightSummaryCardComponent {
1414
flight = input<Flight>();
15+
delete = output<void>();
1516
}

libs/ngrx-hateoas/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@angular-architects/ngrx-hateoas",
3-
"version": "18.0.0",
3+
"version": "18.1.0",
44
"peerDependencies": {
55
"@angular/common": "^18.0.0",
66
"@angular/core": "^18.0.0",

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

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { RequestService } from "../services/request.service";
88
import { HateoasService } from "../services/hateoas.service";
99

1010
export type HypermediaActionStateProps = {
11+
method: '' | 'PUT' | 'POST' | 'DELETE'
1112
href: string
12-
method: '' | 'PUT' | 'POST' | 'DELETE',
1313
isAvailable: boolean
1414
isExecuting: boolean
1515
hasExecutedSuccessfully: boolean
@@ -18,6 +18,17 @@ export type HypermediaActionStateProps = {
1818
error: unknown
1919
}
2020

21+
export const defaultHypermediaActionState: HypermediaActionStateProps = {
22+
href: '',
23+
method: '',
24+
isAvailable: false,
25+
isExecuting: false,
26+
hasExecutedSuccessfully: false,
27+
hasExecutedWithError: false,
28+
hasError: false,
29+
error: null as unknown
30+
}
31+
2132
export type HypermediaActionStoreState<ActionName extends string> =
2233
{
2334
[K in `${ActionName}State`]: HypermediaActionStateProps
@@ -73,25 +84,16 @@ export function withHypermediaAction<ActionName extends string>(actionName: Acti
7384

7485
const stateKey = `${actionName}State`;
7586
const executeMethodName = generateExecuteHypermediaActionMethodName(actionName);
76-
const connectMehtodName = generateConnectHypermediaActionMethodName(actionName);
87+
const connectMethodName = generateConnectHypermediaActionMethodName(actionName);
7788

7889
return signalStoreFeature(
7990
withState({
80-
[stateKey]: {
81-
href: '',
82-
method: '',
83-
isAvailable: false,
84-
isExecuting: false,
85-
hasExecutedSuccessfully: false,
86-
hasExecutedWithError: false,
87-
hasError: false,
88-
error: null as unknown
89-
}
91+
[stateKey]: defaultHypermediaActionState
9092
}),
9193
withMethods((store, requestService = inject(RequestService)) => {
9294

9395
const hateoasService = inject(HateoasService);
94-
let internalResourceLink: Signal<unknown> | null = null;
96+
let internalResourceLink: Signal<unknown> | undefined;
9597

9698
const rxConnectToResource = rxMethod<actionRxInput>(
9799
pipe(
@@ -105,7 +107,7 @@ export function withHypermediaAction<ActionName extends string>(actionName: Acti
105107

106108
return {
107109
[executeMethodName]: async (): Promise<void> => {
108-
if(getState(store, stateKey).isAvailable && internalResourceLink !== null) {
110+
if(getState(store, stateKey).isAvailable && internalResourceLink) {
109111
const method = getState(store, stateKey).method;
110112
const href = getState(store, stateKey).href;
111113

@@ -131,8 +133,8 @@ export function withHypermediaAction<ActionName extends string>(actionName: Acti
131133
}
132134
}
133135
},
134-
[connectMehtodName]: (resourceLink: Signal<unknown>, action: string) => {
135-
if(internalResourceLink === null) {
136+
[connectMethodName]: (resourceLink: Signal<unknown>, action: string) => {
137+
if(!internalResourceLink) {
136138
internalResourceLink = resourceLink;
137139
const input = computed(() => ({ resource: resourceLink(), action }));
138140
rxConnectToResource(input);
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { Signal, computed, inject } from "@angular/core";
2+
import { SignalStoreFeature, patchState, signalStoreFeature, withMethods, withState } from "@ngrx/signals";
3+
import { from, map, mergeMap, pipe, tap } from "rxjs";
4+
import { rxMethod } from '@ngrx/signals/rxjs-interop';
5+
import { isValidActionVerb } from "../util/is-valid-action-verb";
6+
import { isValidHref } from "../util/is-valid-href";
7+
import { RequestService } from "../services/request.service";
8+
import { HateoasService } from "../services/hateoas.service";
9+
import { defaultHypermediaActionState, HypermediaActionStateProps } from "./with-hypermedia-action";
10+
import { Resource, ResourceAction } from "../models";
11+
12+
export type CollectionKey = string | number;
13+
14+
export type HypermediaCollectionActionStateProps = {
15+
method: Record<CollectionKey, '' | 'PUT' | 'POST' | 'DELETE'>
16+
href: Record<CollectionKey, string>
17+
isAvailable: Record<CollectionKey, boolean>
18+
isExecuting: Record<CollectionKey, boolean>
19+
hasExecutedSuccessfully: Record<CollectionKey, boolean>
20+
hasExecutedWithError: Record<CollectionKey, boolean>
21+
hasError: Record<CollectionKey, boolean>
22+
error: Record<CollectionKey, unknown>
23+
}
24+
25+
const defaultHypermediaCollectionActionState: HypermediaCollectionActionStateProps = {
26+
method: {},
27+
href: {},
28+
isAvailable: {},
29+
isExecuting: {},
30+
hasExecutedSuccessfully: {},
31+
hasExecutedWithError: {},
32+
hasError: {},
33+
error: {}
34+
}
35+
36+
export type HypermediaCollectionActionStoreState<ActionName extends string> =
37+
{
38+
[K in `${ActionName}State`]: HypermediaCollectionActionStateProps
39+
};
40+
41+
export type ExecuteHypermediaCollectionActionMethod<ActionName extends string> = {
42+
[K in ActionName]: (id: CollectionKey) => Promise<void>
43+
};
44+
45+
export function generateExecuteHypermediaCollectionActionMethodName(actionName: string) {
46+
return actionName;
47+
}
48+
49+
export type ConnectHypermediaCollectionActionMethod<ActionName extends string> = {
50+
[K in ActionName as `_connect${Capitalize<ActionName>}`]: (resourceLink: Signal<unknown[]>, idKeyName: string, action: string) => void
51+
};
52+
53+
export function generateConnectHypermediaCollectionActionMethodName(actionName: string) {
54+
return `_connect${actionName.charAt(0).toUpperCase() + actionName.slice(1)}`;
55+
}
56+
57+
export type HypermediaCollectionActionMethods<ActionName extends string> =
58+
ExecuteHypermediaCollectionActionMethod<ActionName> & ConnectHypermediaCollectionActionMethod<ActionName>
59+
60+
type ActionRxInput = {
61+
resource: Resource[],
62+
idLookup: (resource: Resource) => CollectionKey,
63+
action: string
64+
}
65+
66+
function getState(store: unknown, stateKey: string): HypermediaCollectionActionStateProps {
67+
return (store as Record<string, Signal<HypermediaCollectionActionStateProps>>)[stateKey]()
68+
}
69+
70+
function updateState(stateKey: string, partialState: Partial<HypermediaCollectionActionStateProps>) {
71+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
72+
return (state: any) => ({ [stateKey]: { ...state[stateKey], ...partialState } });
73+
}
74+
75+
function toResourceMap(resources: Resource[], idLookup: (resource: Resource) => CollectionKey): Record<CollectionKey, Resource> {
76+
const result: Record<CollectionKey, Resource> = {};
77+
resources.forEach(resource => result[idLookup(resource)] = resource);
78+
return result;
79+
}
80+
81+
function updateItemState(stateKey: string, id: CollectionKey, itemState: Partial<HypermediaActionStateProps> ) {
82+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
83+
return (state: { [K in any]: HypermediaCollectionActionStateProps}) => ({ [stateKey]: {
84+
href: itemState.href !== undefined ? { ...state[stateKey].href, [id]: itemState.href } : state[stateKey].href,
85+
method: itemState.method !== undefined ? { ...state[stateKey].method, [id]: itemState.method } : state[stateKey].method,
86+
isAvailable: itemState.isAvailable !== undefined ? { ...state[stateKey].isAvailable, [id]: itemState.isAvailable } : state[stateKey].isAvailable,
87+
isExecuting: itemState.isExecuting !== undefined ? { ...state[stateKey].isExecuting, [id]: itemState.isExecuting } : state[stateKey].isExecuting,
88+
hasExecutedSuccessfully: itemState.hasExecutedSuccessfully !== undefined ? { ...state[stateKey].hasExecutedSuccessfully, [id]: itemState.hasExecutedSuccessfully } : state[stateKey].hasExecutedSuccessfully,
89+
hasExecutedWithError: itemState.hasExecutedWithError !== undefined ? { ...state[stateKey].hasExecutedWithError, [id]: itemState.hasExecutedWithError } : state[stateKey].hasExecutedWithError,
90+
hasError: itemState.hasError !== undefined ? { ...state[stateKey].hasError, [id]: itemState.hasError } : state[stateKey].hasError,
91+
error: itemState.error !== undefined ? { ...state[stateKey].error, [id]: itemState.error } : state[stateKey].error
92+
} satisfies HypermediaCollectionActionStateProps
93+
});
94+
}
95+
96+
export function withHypermediaCollectionAction<ActionName extends string>(
97+
actionName: ActionName): SignalStoreFeature<
98+
{
99+
state: object;
100+
computed: Record<string, Signal<unknown>>;
101+
methods: Record<string, Function>
102+
},
103+
{
104+
state: HypermediaCollectionActionStoreState<ActionName>;
105+
computed: Record<string, Signal<unknown>>;
106+
methods: HypermediaCollectionActionMethods<ActionName>;
107+
}
108+
>;
109+
export function withHypermediaCollectionAction<ActionName extends string>(actionName: ActionName) {
110+
111+
const stateKey = `${actionName}State`;
112+
const executeMethodName = generateExecuteHypermediaCollectionActionMethodName(actionName);
113+
const connectMethodName = generateConnectHypermediaCollectionActionMethodName(actionName);
114+
115+
return signalStoreFeature(
116+
withState({
117+
[stateKey]: defaultHypermediaCollectionActionState
118+
}),
119+
withMethods((store, requestService = inject(RequestService)) => {
120+
121+
const hateoasService = inject(HateoasService);
122+
let internalResourceMap: Signal<Record<CollectionKey, unknown>> | undefined;
123+
124+
const rxConnectToResource = rxMethod<ActionRxInput>(
125+
pipe(
126+
tap(() => patchState(store, updateState(stateKey, defaultHypermediaCollectionActionState))),
127+
mergeMap(input => from(input.resource)
128+
.pipe(
129+
map(resource => [resource, hateoasService.getAction(resource, input.action)] satisfies [Resource, ResourceAction | undefined ] as [Resource, ResourceAction | undefined ]),
130+
map(([resource, action]) => {
131+
const actionState: HypermediaActionStateProps = defaultHypermediaActionState;
132+
if(action && isValidHref(action.href) && isValidActionVerb(action.method)) {
133+
actionState.href = action.href;
134+
actionState.method = action.method;
135+
actionState.isAvailable = true;
136+
}
137+
return [resource, actionState] satisfies [Resource, HypermediaActionStateProps] as [Resource, HypermediaActionStateProps];
138+
}),
139+
tap(([resource, actionState]) => patchState(store, updateItemState(stateKey, input.idLookup(resource), actionState)))
140+
))
141+
)
142+
);
143+
144+
return {
145+
[executeMethodName]: async (id: CollectionKey): Promise<void> => {
146+
if(getState(store, stateKey).isAvailable[id] && internalResourceMap) {
147+
const method = getState(store, stateKey).method[id];
148+
const href = getState(store, stateKey).href[id];
149+
150+
if(!method || !href) throw new Error('Action is not available');
151+
152+
const body = method !== 'DELETE' ? internalResourceMap()[id] : undefined
153+
154+
patchState(store,
155+
updateItemState(stateKey, id, {
156+
isExecuting: true,
157+
hasExecutedSuccessfully: false,
158+
hasExecutedWithError: false,
159+
hasError: false,
160+
error: null
161+
}));
162+
163+
try {
164+
await requestService.request(method, href, body);
165+
patchState(store, updateItemState(stateKey, id, { isExecuting: false, hasExecutedSuccessfully: true } ));
166+
} catch(e) {
167+
patchState(store, updateItemState(stateKey, id, { isExecuting: false, hasExecutedWithError: true, hasError: true, error: e } ));
168+
throw e;
169+
}
170+
}
171+
},
172+
[connectMethodName]: (resourceLink: Signal<Resource[]>, idKeyName: string, action: string) => {
173+
if(!internalResourceMap) {
174+
const idLookup = (resource: Resource) => {
175+
const id = resource[idKeyName];
176+
if(typeof id === 'string' || typeof id === 'number') return id;
177+
else throw new Error("The specified 'idKeyName' must point to a key with a value of type 'string' or 'number'");
178+
};
179+
internalResourceMap = computed(() => toResourceMap(resourceLink(), idLookup));
180+
const input = computed(() => ({ resource: resourceLink(), idLookup, action }));
181+
rxConnectToResource(input);
182+
}
183+
}
184+
};
185+
})
186+
);
187+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export function withLinkedHypermediaResource<ResourceName extends string, TResou
9090

9191
const dataKey = `${resourceName}`;
9292
const stateKey = `${resourceName}State`;
93-
const connectMehtodName = generateConnectLinkedHypermediaResourceMethodName(resourceName);
93+
const connectMethodName = generateConnectLinkedHypermediaResourceMethodName(resourceName);
9494
const reloadMethodName = generateReloadLinkedHypermediaResourceMethodName(resourceName);
9595
const getAsPatchableMethodName = generateGetAsPatchableLinkedHypermediaResourceMethodName(resourceName);
9696

@@ -126,7 +126,7 @@ export function withLinkedHypermediaResource<ResourceName extends string, TResou
126126
);
127127

128128
return {
129-
[connectMehtodName]: (linkRoot: Signal<unknown>, linkName: string) => {
129+
[connectMethodName]: (linkRoot: Signal<unknown>, linkName: string) => {
130130
const input = computed(() => ({ resource: linkRoot(), linkName }));
131131
rxConnectToLinkRoot(input);
132132
},

0 commit comments

Comments
 (0)