Skip to content

Commit 1376916

Browse files
feat: add withEntityResources for managing entity state and resources (#243)
Co-authored-by: Murat Sari <[email protected]>
1 parent 9fcaa03 commit 1376916

16 files changed

+1101
-5
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test.describe('withEntityResources - todos', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('');
6+
await page.getByRole('link', { name: 'withEntityResources' }).click();
7+
});
8+
9+
test('add one todo and remove another', async ({ page }) => {
10+
await expect(page.getByRole('row', { name: 'Buy milk' })).toBeVisible();
11+
await expect(page.getByRole('row', { name: 'Walk the dog' })).toBeVisible();
12+
13+
await page.locator('[data-id="todoer-new"]').click();
14+
await page.locator('[data-id="todoer-new"]').fill('Read a book');
15+
await page.locator('[data-id="todoer-add"]').click();
16+
17+
await expect(page.getByRole('row', { name: 'Read a book' })).toBeVisible();
18+
19+
await page
20+
.getByRole('row', { name: 'Buy milk' })
21+
.locator('[data-id="todoer-delete"]')
22+
.click();
23+
24+
await expect(page.getByRole('row', { name: 'Buy milk' })).toHaveCount(0);
25+
await expect(page.getByRole('row', { name: 'Read a book' })).toBeVisible();
26+
await expect(page.getByRole('row', { name: 'Walk the dog' })).toBeVisible();
27+
});
28+
});

apps/demo/src/app/app.component.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
<a mat-list-item routerLink="/conditional">withConditional</a>
3232
<a mat-list-item routerLink="/mutation">withMutation</a>
3333
<a mat-list-item routerLink="/rx-mutation">rxMutation (without Store)</a>
34+
<a mat-list-item routerLink="/todo-entity-resource"
35+
>withEntityResources</a
36+
>
3437
<a mat-list-item routerLink="/with-resource">withResource</a>
3538
</mat-nav-list>
3639
</mat-drawer>

apps/demo/src/app/app.config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { LayoutModule } from '@angular/cdk/layout';
2-
import { provideHttpClient } from '@angular/common/http';
2+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
33
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
44
import { provideAnimations } from '@angular/platform-browser/animations';
55
import { provideRouter, withComponentInputBinding } from '@angular/router';
66
import { appRoutes } from './app.routes';
7+
import { memoryHttpInterceptor } from './todo-entity-resource/memory-http.interceptor';
78

89
export const appConfig: ApplicationConfig = {
910
providers: [
1011
provideRouter(appRoutes, withComponentInputBinding()),
1112
provideAnimations(),
12-
provideHttpClient(),
13+
provideHttpClient(withInterceptors([memoryHttpInterceptor])),
1314
importProvidersFrom(LayoutModule),
1415
],
1516
};

apps/demo/src/app/lazy-routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ export const lazyRoutes: Route[] = [
7575
(m) => m.CounterRxMutation,
7676
),
7777
},
78+
{
79+
path: 'todo-entity-resource',
80+
loadComponent: () =>
81+
import('./todo-entity-resource/todo-entity-resource.component').then(
82+
(m) => m.TodoEntityResourceComponent,
83+
),
84+
},
7885
{
7986
path: 'with-resource',
8087
loadComponent: () =>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
HttpErrorResponse,
3+
HttpEvent,
4+
HttpInterceptorFn,
5+
HttpRequest,
6+
HttpResponse,
7+
} from '@angular/common/http';
8+
import {
9+
EnvironmentInjector,
10+
inject,
11+
runInInjectionContext,
12+
} from '@angular/core';
13+
import { Observable, of, switchMap } from 'rxjs';
14+
import { Todo, TodoMemoryService } from './todo-memory.service';
15+
16+
function respond<T>(req: HttpRequest<unknown>, body: T): HttpResponse<T> {
17+
return new HttpResponse<T>({
18+
url: req.url,
19+
status: 200,
20+
statusText: 'OK',
21+
body,
22+
});
23+
}
24+
25+
export const memoryHttpInterceptor: HttpInterceptorFn = (
26+
req,
27+
next,
28+
): Observable<HttpEvent<unknown>> => {
29+
const match = req.url.match(/\/memory\/(add|toggle|remove)(?:\/(\d+))?/);
30+
if (!match) return next(req);
31+
32+
// Ensure we resolve service inside an injection context
33+
const env = inject(EnvironmentInjector);
34+
const svc = runInInjectionContext(env, () => inject(TodoMemoryService));
35+
36+
const action = match[1];
37+
const idPart = match[2];
38+
39+
switch (action) {
40+
case 'add': {
41+
const todo = req.body as Todo;
42+
return svc
43+
.add(todo)
44+
.pipe(switchMap((t) => of(respond(req, t) as HttpEvent<unknown>)));
45+
}
46+
case 'toggle': {
47+
const id = Number(idPart);
48+
const completed = (req.body as { completed: boolean }).completed;
49+
return svc.toggle(id, completed).pipe(
50+
switchMap((t) => {
51+
if (t) {
52+
return of(respond(req, t) as HttpEvent<unknown>);
53+
}
54+
const err = new HttpErrorResponse({ url: req.url, status: 404 });
55+
throw err;
56+
}),
57+
);
58+
}
59+
case 'remove': {
60+
const id = Number(idPart);
61+
return svc
62+
.remove(id)
63+
.pipe(switchMap((ok) => of(respond(req, ok) as HttpEvent<unknown>)));
64+
}
65+
default:
66+
return next(req);
67+
}
68+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<div class="toolbar">
2+
<mat-form-field appearance="outline" class="filter">
3+
<mat-label>Filter</mat-label>
4+
<input
5+
matInput
6+
[ngModel]="store.filter()"
7+
(ngModelChange)="store.setFilter($event)"
8+
data-id="todoer-filter"
9+
placeholder="Type to filter todos"
10+
/>
11+
</mat-form-field>
12+
13+
<mat-form-field appearance="outline" class="new-item">
14+
<mat-label>New todo</mat-label>
15+
<input
16+
matInput
17+
[(ngModel)]="newTitle"
18+
(keyup.enter)="add()"
19+
data-id="todoer-new"
20+
placeholder="New todo"
21+
/>
22+
</mat-form-field>
23+
<button
24+
mat-raised-button
25+
color="primary"
26+
(click)="add()"
27+
data-id="todoer-add"
28+
>
29+
Add
30+
</button>
31+
</div>
32+
33+
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
34+
<ng-container matColumnDef="completed">
35+
<mat-header-cell *matHeaderCellDef></mat-header-cell>
36+
<mat-cell *matCellDef="let row" class="actions">
37+
<mat-icon
38+
(click)="store.toggleTodo({ id: row.id, completed: !row.completed })"
39+
data-id="todoer-toggle"
40+
>
41+
{{ row.completed ? 'check_box' : 'check_box_outline_blank' }}
42+
</mat-icon>
43+
<mat-icon
44+
color="warn"
45+
(click)="store.removeTodo(row.id)"
46+
data-id="todoer-delete"
47+
aria-label="delete"
48+
>delete</mat-icon
49+
>
50+
</mat-cell>
51+
</ng-container>
52+
53+
<ng-container matColumnDef="title">
54+
<mat-header-cell *matHeaderCellDef>Title</mat-header-cell>
55+
<mat-cell *matCellDef="let element">{{ element.title }}</mat-cell>
56+
</ng-container>
57+
58+
<mat-header-row *matHeaderRowDef="['completed', 'title']"></mat-header-row>
59+
<mat-row *matRowDef="let row; columns: ['completed', 'title']"></mat-row>
60+
</mat-table>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { CommonModule } from '@angular/common';
2+
import { Component, computed, effect, inject } from '@angular/core';
3+
import { FormsModule } from '@angular/forms';
4+
import { MatIcon } from '@angular/material/icon';
5+
import { MatInputModule } from '@angular/material/input';
6+
import { MatListModule } from '@angular/material/list';
7+
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
8+
import { TodoEntityResourceStore } from './todo-entity-resource.store';
9+
10+
@Component({
11+
selector: 'demo-todo-entity-resource',
12+
standalone: true,
13+
imports: [
14+
CommonModule,
15+
FormsModule,
16+
MatIcon,
17+
MatInputModule,
18+
MatListModule,
19+
MatTableModule,
20+
],
21+
templateUrl: './todo-entity-resource.component.html',
22+
styles: [],
23+
})
24+
export class TodoEntityResourceComponent {
25+
protected readonly store = inject(TodoEntityResourceStore);
26+
protected newTitle = '';
27+
protected readonly dataSource = new MatTableDataSource<{
28+
id: number;
29+
title: string;
30+
completed: boolean;
31+
}>([]);
32+
protected readonly filtered = computed(() =>
33+
this.store.entities().filter((t) =>
34+
(this.store.filter() || '')
35+
.toLowerCase()
36+
.split(/\s+/)
37+
.filter((s) => s.length > 0)
38+
.every((s) => t.title.toLowerCase().includes(s)),
39+
),
40+
);
41+
constructor() {
42+
effect(() => {
43+
this.dataSource.data = this.filtered();
44+
});
45+
}
46+
trackById = (_: number, t: { id: number }) => t.id;
47+
add() {
48+
const title = this.newTitle.trim();
49+
if (!title) return;
50+
const ids = this.store.ids() as Array<number>;
51+
const nextId = ids.length ? Math.max(...ids) + 1 : 1;
52+
this.store.addTodo({ id: nextId, title, completed: false });
53+
this.newTitle = '';
54+
}
55+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {
2+
httpMutation,
3+
withEntityResources,
4+
withMutations,
5+
} from '@angular-architects/ngrx-toolkit';
6+
import { inject, resource } from '@angular/core';
7+
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
8+
import { addEntity, removeEntity, updateEntity } from '@ngrx/signals/entities';
9+
import { firstValueFrom } from 'rxjs';
10+
import { Todo, TodoMemoryService } from './todo-memory.service';
11+
12+
export const TodoEntityResourceStore = signalStore(
13+
{ providedIn: 'root' },
14+
withState({ baseUrl: '/api', filter: '' }),
15+
withEntityResources((store, svc = inject(TodoMemoryService)) =>
16+
resource({ loader: () => firstValueFrom(svc.list()), defaultValue: [] }),
17+
),
18+
withMethods((store) => ({
19+
setFilter(filter: string) {
20+
patchState(store, { filter });
21+
},
22+
})),
23+
withMutations((store, svc = inject(TodoMemoryService)) => ({
24+
addTodo: httpMutation<Todo, Todo>({
25+
request: (todo) => ({ url: '/memory/add', method: 'POST', body: todo }),
26+
parse: (raw) => raw as Todo,
27+
onSuccess: async (todo) => {
28+
await firstValueFrom(svc.add(todo));
29+
patchState(store, addEntity(todo));
30+
},
31+
}),
32+
toggleTodo: httpMutation<{ id: number; completed: boolean }, Todo>({
33+
request: (p) => ({
34+
url: `/memory/toggle/${p.id}`,
35+
method: 'PATCH',
36+
body: p,
37+
}),
38+
parse: (raw) => raw as Todo,
39+
onSuccess: async (_todo, p) => {
40+
const todo = await firstValueFrom(svc.toggle(p.id, p.completed));
41+
if (todo) {
42+
patchState(
43+
store,
44+
updateEntity<Todo>({
45+
id: todo.id,
46+
changes: { completed: todo.completed },
47+
}),
48+
);
49+
}
50+
},
51+
}),
52+
removeTodo: httpMutation<number, boolean>({
53+
request: (id) => ({ url: `/memory/remove/${id}`, method: 'DELETE' }),
54+
parse: () => true,
55+
onSuccess: async (_r, id) => {
56+
await firstValueFrom(svc.remove(id));
57+
patchState(store, removeEntity(id));
58+
},
59+
}),
60+
})),
61+
);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Injectable } from '@angular/core';
2+
import { BehaviorSubject, Observable, of } from 'rxjs';
3+
4+
export interface Todo {
5+
id: number;
6+
title: string;
7+
completed: boolean;
8+
}
9+
10+
@Injectable({ providedIn: 'root' })
11+
export class TodoMemoryService {
12+
private readonly todos$ = new BehaviorSubject<Todo[]>([
13+
{ id: 1, title: 'Buy milk', completed: false },
14+
{ id: 2, title: 'Walk the dog', completed: true },
15+
]);
16+
17+
list(): Observable<Todo[]> {
18+
return this.todos$.asObservable();
19+
}
20+
21+
add(todo: Todo): Observable<Todo> {
22+
const list = this.todos$.value.slice();
23+
list.push(todo);
24+
this.todos$.next(list);
25+
return of(todo);
26+
}
27+
28+
toggle(id: number, completed: boolean): Observable<Todo | undefined> {
29+
const list = this.todos$.value.slice();
30+
const idx = list.findIndex((t) => t.id === id);
31+
if (idx >= 0) {
32+
list[idx] = { ...list[idx], completed };
33+
this.todos$.next(list);
34+
return of(list[idx]);
35+
}
36+
return of(undefined);
37+
}
38+
39+
remove(id: number): Observable<boolean> {
40+
const list = this.todos$.value.slice();
41+
const filtered = list.filter((t) => t.id !== id);
42+
this.todos$.next(filtered);
43+
return of(true);
44+
}
45+
}

docs/docs/extensions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ It offers extensions like:
1212
- [Immutable State Protection](./with-immutable-state): Protects the state from being mutated outside or inside the Store.
1313
- [~Redux~](./with-redux): Possibility to use the Redux Pattern. Deprecated in favor of NgRx's `@ngrx/signals/events` starting in 19.2
1414
- [Resource](./with-resource): Integrates Angular's Resource into SignalStore for async data operations
15+
- [Entity Resources](./with-entity-resources): Builds on top of [withResource](./with-resource); adds entity support for array resources (`ids`, `entityMap`, `entities`)
1516
- [Mutations](./mutations): Seek to offer an appropriate equivalent to signal resources for sending data back to the backend
1617
- [Reset](./with-reset): Adds a `resetState` method to your store
1718
- [Call State](./with-call-state): Add call state management to your signal stores

0 commit comments

Comments
 (0)