Skip to content

Commit 9c539d3

Browse files
authored
Merge branch 'angular-architects:main' into main
2 parents 92a49be + 40217bb commit 9c539d3

28 files changed

+1794
-47
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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
<mat-toolbar color="primary">
2+
<button matIconButton (click)="drawer.toggle()">
3+
<mat-icon>menu</mat-icon>
4+
</button>
25
<span>NgRx Toolkit Demo</span>
36
</mat-toolbar>
47
<mat-drawer-container class="container">
5-
<mat-drawer mode="side" opened>
8+
<mat-drawer mode="side" #drawer [opened]="opened()">
69
<mat-nav-list>
710
<a mat-list-item routerLink="/todo">DevTools</a>
811
<a mat-list-item routerLink="/events-sample">Events + DevTools Sample</a>
@@ -29,6 +32,10 @@
2932
<a mat-list-item routerLink="/conditional">withConditional</a>
3033
<a mat-list-item routerLink="/mutation">withMutation</a>
3134
<a mat-list-item routerLink="/rx-mutation">rxMutation (without Store)</a>
35+
<a mat-list-item routerLink="/todo-entity-resource"
36+
>withEntityResources</a
37+
>
38+
<a mat-list-item routerLink="/with-resource">withResource</a>
3239
</mat-nav-list>
3340
</mat-drawer>
3441
<mat-drawer-content>

apps/demo/src/app/app.component.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import { CommonModule } from '@angular/common';
2-
import { Component } from '@angular/core';
1+
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
2+
import { Component, inject } from '@angular/core';
3+
import { toSignal } from '@angular/core/rxjs-interop';
4+
import { MatButtonModule } from '@angular/material/button';
35
import { MatCheckboxModule } from '@angular/material/checkbox';
46
import { MatIconModule } from '@angular/material/icon';
57
import { MatListModule } from '@angular/material/list';
6-
import {
7-
MatDrawer,
8-
MatDrawerContainer,
9-
MatDrawerContent,
10-
} from '@angular/material/sidenav';
8+
import { MatSidenavModule } from '@angular/material/sidenav';
119
import { MatTableModule } from '@angular/material/table';
1210
import { MatToolbarModule } from '@angular/material/toolbar';
1311
import { RouterLink, RouterOutlet } from '@angular/router';
12+
import { map } from 'rxjs';
1413

1514
@Component({
1615
selector: 'demo-root',
@@ -22,11 +21,9 @@ import { RouterLink, RouterOutlet } from '@angular/router';
2221
MatListModule,
2322
RouterLink,
2423
RouterOutlet,
25-
CommonModule,
2624
MatToolbarModule,
27-
MatDrawer,
28-
MatDrawerContainer,
29-
MatDrawerContent,
25+
MatSidenavModule,
26+
MatButtonModule,
3027
],
3128
styles: `
3229
.container {
@@ -37,4 +34,18 @@ import { RouterLink, RouterOutlet } from '@angular/router';
3734
}
3835
`,
3936
})
40-
export class AppComponent {}
37+
export class AppComponent {
38+
opened = toSignal(
39+
inject(BreakpointObserver)
40+
.observe([Breakpoints.XSmall, Breakpoints.Small])
41+
.pipe(
42+
map(
43+
({ breakpoints }) =>
44+
!(
45+
breakpoints[Breakpoints.XSmall] || breakpoints[Breakpoints.Small]
46+
),
47+
),
48+
),
49+
{ requireSync: true },
50+
);
51+
}

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/counter-rx-mutation/counter-rx-mutation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export type CounterResponse = {
1616
json: { counter: number };
1717
};
1818

19+
// TODO - rename this file to just be `mutations-functions-standalone` + class/selector etc??
20+
// And then the other folder to "store"
21+
// Or maybe put these all in one folder too while we are at it?
1922
@Component({
2023
selector: 'demo-counter-rx-mutation',
2124
imports: [CommonModule],

apps/demo/src/app/devtools/todo-store.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@ export const TodoStore = signalStore(
3030
},
3131

3232
remove(id: number) {
33-
updateState(store, 'remove todo', removeEntity(id));
33+
updateState(
34+
store,
35+
'remove todo',
36+
removeEntity(id),
37+
({ selectedIds }) => ({
38+
selectedIds: selectedIds.filter((selectedId) => selectedId !== id),
39+
}),
40+
);
3441
},
3542

3643
toggleFinished(id: number): void {

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,18 @@ export const lazyRoutes: Route[] = [
7777
(m) => m.CounterRxMutation,
7878
),
7979
},
80+
{
81+
path: 'todo-entity-resource',
82+
loadComponent: () =>
83+
import('./todo-entity-resource/todo-entity-resource.component').then(
84+
(m) => m.TodoEntityResourceComponent,
85+
),
86+
},
87+
{
88+
path: 'with-resource',
89+
loadComponent: () =>
90+
import('./with-resource/with-resource.component').then(
91+
(m) => m.WithResourceComponent,
92+
),
93+
},
8094
];
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+
}

0 commit comments

Comments
 (0)