Skip to content
Open
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
28 changes: 28 additions & 0 deletions apps/demo/e2e/todo-entity-resource.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test } from '@playwright/test';

test.describe('withEntityResources - todos', () => {
test.beforeEach(async ({ page }) => {
await page.goto('');
await page.getByRole('link', { name: 'withEntityResources' }).click();
});

test('add one todo and remove another', async ({ page }) => {
await expect(page.getByRole('row', { name: 'Buy milk' })).toBeVisible();
await expect(page.getByRole('row', { name: 'Walk the dog' })).toBeVisible();

await page.locator('[data-id="todoer-new"]').click();
await page.locator('[data-id="todoer-new"]').fill('Read a book');
await page.locator('[data-id="todoer-add"]').click();

await expect(page.getByRole('row', { name: 'Read a book' })).toBeVisible();

await page
.getByRole('row', { name: 'Buy milk' })
.locator('[data-id="todoer-delete"]')
.click();

await expect(page.getByRole('row', { name: 'Buy milk' })).toHaveCount(0);
await expect(page.getByRole('row', { name: 'Read a book' })).toBeVisible();
await expect(page.getByRole('row', { name: 'Walk the dog' })).toBeVisible();
});
});
3 changes: 3 additions & 0 deletions apps/demo/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
<a mat-list-item routerLink="/conditional">withConditional</a>
<a mat-list-item routerLink="/mutation">withMutation</a>
<a mat-list-item routerLink="/rx-mutation">rxMutation (without Store)</a>
<a mat-list-item routerLink="/todo-entity-resource"
>withEntityResources</a
>
</mat-nav-list>
</mat-drawer>
<mat-drawer-content>
Expand Down
5 changes: 3 additions & 2 deletions apps/demo/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { LayoutModule } from '@angular/cdk/layout';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { appRoutes } from './app.routes';
import { memoryHttpInterceptor } from './todo-entity-resource/memory-http.interceptor';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(appRoutes, withComponentInputBinding()),
provideAnimations(),
provideHttpClient(),
provideHttpClient(withInterceptors([memoryHttpInterceptor])),
importProvidersFrom(LayoutModule),
],
};
7 changes: 7 additions & 0 deletions apps/demo/src/app/lazy-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,11 @@ export const lazyRoutes: Route[] = [
(m) => m.CounterRxMutation,
),
},
{
path: 'todo-entity-resource',
loadComponent: () =>
import('./todo-entity-resource/todo-entity-resource.component').then(
(m) => m.TodoEntityResourceComponent,
),
},
];
68 changes: 68 additions & 0 deletions apps/demo/src/app/todo-entity-resource/memory-http.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
HttpErrorResponse,
HttpEvent,
HttpInterceptorFn,
HttpRequest,
HttpResponse,
} from '@angular/common/http';
import {
EnvironmentInjector,
inject,
runInInjectionContext,
} from '@angular/core';
import { Observable, of, switchMap } from 'rxjs';
import { Todo, TodoMemoryService } from './todo-memory.service';

function respond<T>(req: HttpRequest<unknown>, body: T): HttpResponse<T> {
return new HttpResponse<T>({
url: req.url,
status: 200,
statusText: 'OK',
body,
});
}

export const memoryHttpInterceptor: HttpInterceptorFn = (
req,
next,
): Observable<HttpEvent<unknown>> => {
const match = req.url.match(/\/memory\/(add|toggle|remove)(?:\/(\d+))?/);
if (!match) return next(req);

// Ensure we resolve service inside an injection context
const env = inject(EnvironmentInjector);
const svc = runInInjectionContext(env, () => inject(TodoMemoryService));

const action = match[1];
const idPart = match[2];

switch (action) {
case 'add': {
const todo = req.body as Todo;
return svc
.add(todo)
.pipe(switchMap((t) => of(respond(req, t) as HttpEvent<unknown>)));
}
case 'toggle': {
const id = Number(idPart);
const completed = (req.body as { completed: boolean }).completed;
return svc.toggle(id, completed).pipe(
switchMap((t) => {
if (t) {
return of(respond(req, t) as HttpEvent<unknown>);
}
const err = new HttpErrorResponse({ url: req.url, status: 404 });
throw err;
}),
);
}
case 'remove': {
const id = Number(idPart);
return svc
.remove(id)
.pipe(switchMap((ok) => of(respond(req, ok) as HttpEvent<unknown>)));
}
default:
return next(req);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<div class="toolbar">
<mat-form-field appearance="outline" class="filter">
<mat-label>Filter</mat-label>
<input
matInput
[ngModel]="store.filter()"
(ngModelChange)="store.setFilter($event)"
data-id="todoer-filter"
placeholder="Type to filter todos"
/>
</mat-form-field>

<mat-form-field appearance="outline" class="new-item">
<mat-label>New todo</mat-label>
<input
matInput
[(ngModel)]="newTitle"
(keyup.enter)="add()"
data-id="todoer-new"
placeholder="New todo"
/>
</mat-form-field>
<button
mat-raised-button
color="primary"
(click)="add()"
data-id="todoer-add"
>
Add
</button>
</div>

<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="completed">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row" class="actions">
<mat-icon
(click)="store.toggleTodo({ id: row.id, completed: !row.completed })"
data-id="todoer-toggle"
>
{{ row.completed ? 'check_box' : 'check_box_outline_blank' }}
</mat-icon>
<mat-icon
color="warn"
(click)="store.removeTodo(row.id)"
data-id="todoer-delete"
aria-label="delete"
>delete</mat-icon
>
</mat-cell>
</ng-container>

<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef>Title</mat-header-cell>
<mat-cell *matCellDef="let element">{{ element.title }}</mat-cell>
</ng-container>

<mat-header-row *matHeaderRowDef="['completed', 'title']"></mat-header-row>
<mat-row *matRowDef="let row; columns: ['completed', 'title']"></mat-row>
</mat-table>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { TodoEntityResourceStore } from './todo-entity-resource.store';

@Component({
selector: 'demo-todo-entity-resource',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIcon,
MatInputModule,
MatListModule,
MatTableModule,
],
templateUrl: './todo-entity-resource.component.html',
styles: [],
})
export class TodoEntityResourceComponent {
protected readonly store = inject(TodoEntityResourceStore);
protected newTitle = '';
protected readonly dataSource = new MatTableDataSource<{
id: number;
title: string;
completed: boolean;
}>([]);
protected readonly filtered = computed(() =>
this.store.entities().filter((t) =>
(this.store.filter() || '')
.toLowerCase()
.split(/\s+/)
.filter((s) => s.length > 0)
.every((s) => t.title.toLowerCase().includes(s)),
),
);
constructor() {
effect(() => {
this.dataSource.data = this.filtered();
});
}
trackById = (_: number, t: { id: number }) => t.id;
add() {
const title = this.newTitle.trim();
if (!title) return;
const ids = this.store.ids() as Array<number>;
const nextId = ids.length ? Math.max(...ids) + 1 : 1;
this.store.addTodo({ id: nextId, title, completed: false });
this.newTitle = '';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
httpMutation,
withEntityResources,
withMutations,
} from '@angular-architects/ngrx-toolkit';
import { inject, resource } from '@angular/core';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { addEntity, removeEntity, updateEntity } from '@ngrx/signals/entities';
import { firstValueFrom } from 'rxjs';
import { Todo, TodoMemoryService } from './todo-memory.service';

export const TodoEntityResourceStore = signalStore(
{ providedIn: 'root' },
withState({ baseUrl: '/api', filter: '' }),
withEntityResources((store, svc = inject(TodoMemoryService)) =>
resource({ loader: () => firstValueFrom(svc.list()), defaultValue: [] }),
),
withMethods((store) => ({
setFilter(filter: string) {
patchState(store, { filter });
},
})),
withMutations((store, svc = inject(TodoMemoryService)) => ({
addTodo: httpMutation<Todo, Todo>({
request: (todo) => ({ url: '/memory/add', method: 'POST', body: todo }),
parse: (raw) => raw as Todo,
onSuccess: async (todo) => {
await firstValueFrom(svc.add(todo));
patchState(store, addEntity(todo));
},
}),
toggleTodo: httpMutation<{ id: number; completed: boolean }, Todo>({
request: (p) => ({
url: `/memory/toggle/${p.id}`,
method: 'PATCH',
body: p,
}),
parse: (raw) => raw as Todo,
onSuccess: async (_todo, p) => {
const todo = await firstValueFrom(svc.toggle(p.id, p.completed));
if (todo) {
patchState(
store,
updateEntity<Todo>({
id: todo.id,
changes: { completed: todo.completed },
}),
);
}
},
}),
removeTodo: httpMutation<number, boolean>({
request: (id) => ({ url: `/memory/remove/${id}`, method: 'DELETE' }),
parse: () => true,
onSuccess: async (_r, id) => {
await firstValueFrom(svc.remove(id));
patchState(store, removeEntity(id));
},
}),
})),
);
45 changes: 45 additions & 0 deletions apps/demo/src/app/todo-entity-resource/todo-memory.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of } from 'rxjs';

export interface Todo {
id: number;
title: string;
completed: boolean;
}

@Injectable({ providedIn: 'root' })
export class TodoMemoryService {
private readonly todos$ = new BehaviorSubject<Todo[]>([
{ id: 1, title: 'Buy milk', completed: false },
{ id: 2, title: 'Walk the dog', completed: true },
]);

list(): Observable<Todo[]> {
return this.todos$.asObservable();
}

add(todo: Todo): Observable<Todo> {
const list = this.todos$.value.slice();
list.push(todo);
this.todos$.next(list);
return of(todo);
}

toggle(id: number, completed: boolean): Observable<Todo | undefined> {
const list = this.todos$.value.slice();
const idx = list.findIndex((t) => t.id === id);
if (idx >= 0) {
list[idx] = { ...list[idx], completed };
this.todos$.next(list);
return of(list[idx]);
}
return of(undefined);
}

remove(id: number): Observable<boolean> {
const list = this.todos$.value.slice();
const filtered = list.filter((t) => t.id !== id);
this.todos$.next(filtered);
return of(true);
}
}
1 change: 1 addition & 0 deletions docs/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ It offers extensions like:
- [Immutable State Protection](./with-immutable-state): Protects the state from being mutated outside or inside the Store.
- [~Redux~](./with-redux): Possibility to use the Redux Pattern. Deprecated in favor of NgRx's `@ngrx/signals/events` starting in 19.2
- [Resource](./with-resource): Integrates Angular's Resource into SignalStore for async data operations
- [Entity Resources](./with-entity-resources): Builds on top of [withResource](./with-resource); adds entity support for array resources (`ids`, `entityMap`, `entities`)
- [Mutations](./mutations): Seek to offer an appropriate equivalent to signal resources for sending data back to the backend
- [Reset](./with-reset): Adds a `resetState` method to your store
- [Call State](./with-call-state): Add call state management to your signal stores
Expand Down
Loading