feat: Implement tasks feature using NGRX signals and remove the old counter store, alongside general project configuration and skill documentation updates.
continuous-integration/drone/pr Build is passing

This commit is contained in:
Dennis Hundertmark
2026-03-08 09:50:17 +01:00
parent 2184971175
commit 9d13cc652a
47 changed files with 15272 additions and 14144 deletions
+119 -129
View File
@@ -12,34 +12,34 @@ Fetch data in Angular using signal-based `resource()`, `httpResource()`, and the
`httpResource()` wraps HttpClient with signal-based state management:
```typescript
import { Component, signal } from "@angular/core";
import { httpResource } from "@angular/common/http";
import { Component, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
interface User {
id: number;
name: string;
email: string;
id: number;
name: string;
email: string;
}
@Component({
selector: "app-user-profile",
template: `
@if (userResource.isLoading()) {
<p>Loading...</p>
} @else if (userResource.error()) {
<p>Error: {{ userResource.error()?.message }}</p>
<button (click)="userResource.reload()">Retry</button>
} @else if (userResource.hasValue()) {
<h1>{{ userResource.value().name }}</h1>
<p>{{ userResource.value().email }}</p>
}
`,
selector: 'app-user-profile',
template: `
@if (userResource.isLoading()) {
<p>Loading...</p>
} @else if (userResource.error()) {
<p>Error: {{ userResource.error()?.message }}</p>
<button (click)="userResource.reload()">Retry</button>
} @else if (userResource.hasValue()) {
<h1>{{ userResource.value().name }}</h1>
<p>{{ userResource.value().email }}</p>
}
`,
})
export class UserProfile {
userId = signal("123");
userId = signal('123');
// Reactive HTTP resource - refetches when userId changes
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
// Reactive HTTP resource - refetches when userId changes
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
}
```
@@ -51,21 +51,21 @@ userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
// With full request options
userResource = httpResource<User>(() => ({
url: `/api/users/${this.userId()}`,
method: "GET",
headers: { Authorization: `Bearer ${this.token()}` },
params: { include: "profile" },
url: `/api/users/${this.userId()}`,
method: 'GET',
headers: { Authorization: `Bearer ${this.token()}` },
params: { include: 'profile' },
}));
// With default value
usersResource = httpResource<User[]>(() => "/api/users", {
defaultValue: [],
usersResource = httpResource<User[]>(() => '/api/users', {
defaultValue: [],
});
// Skip request when params undefined
userResource = httpResource<User>(() => {
const id = this.userId();
return id ? `/api/users/${id}` : undefined;
const id = this.userId();
return id ? `/api/users/${id}` : undefined;
});
```
@@ -117,12 +117,12 @@ export class Search {
```typescript
todosResource = resource({
defaultValue: [] as Todo[],
params: () => ({ filter: this.filter() }),
loader: async ({ params }) => {
const res = await fetch(`/api/todos?filter=${params.filter}`);
return res.json();
},
defaultValue: [] as Todo[],
params: () => ({ filter: this.filter() }),
loader: async ({ params }) => {
const res = await fetch(`/api/todos?filter=${params.filter}`);
return res.json();
},
});
// value() returns Todo[] (never undefined)
@@ -134,14 +134,14 @@ todosResource = resource({
const userId = signal<string | null>(null);
userResource = resource({
params: () => {
const id = userId();
// Return undefined to skip loading
return id ? { id } : undefined;
},
loader: async ({ params }) => {
return fetch(`/api/users/${params.id}`).then((r) => r.json());
},
params: () => {
const id = userId();
// Return undefined to skip loading
return id ? { id } : undefined;
},
loader: async ({ params }) => {
return fetch(`/api/users/${params.id}`).then((r) => r.json());
},
});
// Status is 'idle' when params returns undefined
```
@@ -204,18 +204,18 @@ deleteUser(id: string) {
### Request Options
```typescript
this.http.get<User[]>("/api/users", {
headers: {
Authorization: "Bearer token",
"Content-Type": "application/json",
},
params: {
page: "1",
limit: "10",
sort: "name",
},
observe: "response", // Get full HttpResponse
responseType: "json",
this.http.get<User[]>('/api/users', {
headers: {
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
},
params: {
page: '1',
limit: '10',
sort: 'name',
},
observe: 'response', // Get full HttpResponse
responseType: 'json',
});
```
@@ -225,44 +225,43 @@ this.http.get<User[]>("/api/users", {
```typescript
// auth.interceptor.ts
import { HttpInterceptorFn } from "@angular/common/http";
import { inject } from "@angular/core";
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(Auth);
const token = authService.token();
const authService = inject(Auth);
const token = authService.token();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next(req);
return next(req);
};
// error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
inject(Router).navigate(["/login"]);
}
return throwError(() => error);
}),
);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
inject(Router).navigate(['/login']);
}
return throwError(() => error);
}),
);
};
// logging.interceptor.ts
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const started = Date.now();
return next(req).pipe(
tap({
next: () =>
console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`),
error: (err) => console.error(`${req.method} ${req.url} failed`, err),
}),
);
const started = Date.now();
return next(req).pipe(
tap({
next: () => console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`),
error: (err) => console.error(`${req.method} ${req.url} failed`, err),
}),
);
};
```
@@ -270,14 +269,10 @@ export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
```typescript
// app.config.ts
import { provideHttpClient, withInterceptors } from "@angular/common/http";
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor]),
),
],
providers: [provideHttpClient(withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor]))],
};
```
@@ -287,26 +282,24 @@ export const appConfig: ApplicationConfig = {
```typescript
@Component({
template: `
@if (userResource.error(); as error) {
<div class="error">
<p>{{ getErrorMessage(error) }}</p>
<button (click)="userResource.reload()">Retry</button>
</div>
}
`,
template: `
@if (userResource.error(); as error) {
<div class="error">
<p>{{ getErrorMessage(error) }}</p>
<button (click)="userResource.reload()">Retry</button>
</div>
}
`,
})
export class UserCmpt {
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
getErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
return (
error.error?.message || `Error ${error.status}: ${error.statusText}`
);
getErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
return error.error?.message || `Error ${error.status}: ${error.statusText}`;
}
return 'An unexpected error occurred';
}
return "An unexpected error occurred";
}
}
```
@@ -330,35 +323,32 @@ getUser(id: string) {
```typescript
@Component({
template: `
@switch (dataResource.status()) {
@case ("idle") {
<p>Enter a search term</p>
}
@case ("loading") {
<app-spinner />
}
@case ("reloading") {
<app-data [data]="dataResource.value()" />
<app-spinner size="small" />
}
@case ("resolved") {
<app-data [data]="dataResource.value()" />
}
@case ("error") {
<app-error
[error]="dataResource.error()"
(retry)="dataResource.reload()"
/>
}
}
`,
template: `
@switch (dataResource.status()) {
@case ('idle') {
<p>Enter a search term</p>
}
@case ('loading') {
<app-spinner />
}
@case ('reloading') {
<app-data [data]="dataResource.value()" />
<app-spinner size="small" />
}
@case ('resolved') {
<app-data [data]="dataResource.value()" />
}
@case ('error') {
<app-error
[error]="dataResource.error()"
(retry)="dataResource.reload()" />
}
}
`,
})
export class Data {
query = signal("");
dataResource = httpResource<Data[]>(() =>
this.query() ? `/api/search?q=${this.query()}` : undefined,
);
query = signal('');
dataResource = httpResource<Data[]>(() => (this.query() ? `/api/search?q=${this.query()}` : undefined));
}
```
@@ -14,55 +14,55 @@
Encapsulate HTTP logic in services:
```typescript
import { Injectable, inject, signal, computed } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { httpResource } from "@angular/common/http";
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { httpResource } from '@angular/common/http';
export interface User {
id: string;
name: string;
email: string;
id: string;
name: string;
email: string;
}
@Injectable({ providedIn: "root" })
@Injectable({ providedIn: 'root' })
export class User {
private http = inject(HttpClient);
private baseUrl = "/api/users";
private http = inject(HttpClient);
private baseUrl = '/api/users';
// Current user ID for reactive fetching
private currentUserId = signal<string | null>(null);
// Current user ID for reactive fetching
private currentUserId = signal<string | null>(null);
// Reactive resource that updates when currentUserId changes
currentUser = httpResource<User>(() => {
const id = this.currentUserId();
return id ? `${this.baseUrl}/${id}` : undefined;
});
// Reactive resource that updates when currentUserId changes
currentUser = httpResource<User>(() => {
const id = this.currentUserId();
return id ? `${this.baseUrl}/${id}` : undefined;
});
// Set current user to fetch
selectUser(id: string) {
this.currentUserId.set(id);
}
// Set current user to fetch
selectUser(id: string) {
this.currentUserId.set(id);
}
// CRUD operations
getAll() {
return this.http.get<User[]>(this.baseUrl);
}
// CRUD operations
getAll() {
return this.http.get<User[]>(this.baseUrl);
}
getById(id: string) {
return this.http.get<User>(`${this.baseUrl}/${id}`);
}
getById(id: string) {
return this.http.get<User>(`${this.baseUrl}/${id}`);
}
create(user: Omit<User, "id">) {
return this.http.post<User>(this.baseUrl, user);
}
create(user: Omit<User, 'id'>) {
return this.http.post<User>(this.baseUrl, user);
}
update(id: string, user: Partial<User>) {
return this.http.patch<User>(`${this.baseUrl}/${id}`, user);
}
update(id: string, user: Partial<User>) {
return this.http.patch<User>(`${this.baseUrl}/${id}`, user);
}
delete(id: string) {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
delete(id: string) {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
```
@@ -71,67 +71,67 @@ export class User {
### Simple In-Memory Cache
```typescript
@Injectable({ providedIn: "root" })
@Injectable({ providedIn: 'root' })
export class CachedUser {
private http = inject(HttpClient);
private cache = new Map<string, { data: User; timestamp: number }>();
private cacheDuration = 5 * 60 * 1000; // 5 minutes
private http = inject(HttpClient);
private cache = new Map<string, { data: User; timestamp: number }>();
private cacheDuration = 5 * 60 * 1000; // 5 minutes
getUser(id: string): Observable<User> {
const cached = this.cache.get(id);
getUser(id: string): Observable<User> {
const cached = this.cache.get(id);
if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
return of(cached.data);
if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
return of(cached.data);
}
return this.http.get<User>(`/api/users/${id}`).pipe(
tap((user) => {
this.cache.set(id, { data: user, timestamp: Date.now() });
}),
);
}
return this.http.get<User>(`/api/users/${id}`).pipe(
tap((user) => {
this.cache.set(id, { data: user, timestamp: Date.now() });
}),
);
}
invalidateCache(id?: string) {
if (id) {
this.cache.delete(id);
} else {
this.cache.clear();
invalidateCache(id?: string) {
if (id) {
this.cache.delete(id);
} else {
this.cache.clear();
}
}
}
}
```
### Signal-Based Cache
```typescript
@Injectable({ providedIn: "root" })
@Injectable({ providedIn: 'root' })
export class UserCache {
private http = inject(HttpClient);
private http = inject(HttpClient);
// Cache as signal
private usersCache = signal<Map<string, User>>(new Map());
// Cache as signal
private usersCache = signal<Map<string, User>>(new Map());
// Computed for easy access
users = computed(() => Array.from(this.usersCache().values()));
// Computed for easy access
users = computed(() => Array.from(this.usersCache().values()));
getUser(id: string): User | undefined {
return this.usersCache().get(id);
}
getUser(id: string): User | undefined {
return this.usersCache().get(id);
}
async fetchUser(id: string): Promise<User> {
const cached = this.getUser(id);
if (cached) return cached;
async fetchUser(id: string): Promise<User> {
const cached = this.getUser(id);
if (cached) return cached;
const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
this.usersCache.update((cache) => {
const newCache = new Map(cache);
newCache.set(id, user);
return newCache;
});
this.usersCache.update((cache) => {
const newCache = new Map(cache);
newCache.set(id, user);
return newCache;
});
return user;
}
return user;
}
}
```
@@ -141,58 +141,61 @@ export class UserCache {
```typescript
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
@Component({
template: `
@if (usersResource.isLoading()) {
<app-spinner />
} @else if (usersResource.hasValue()) {
<ul>
@for (user of usersResource.value().data; track user.id) {
<li>{{ user.name }}</li>
template: `
@if (usersResource.isLoading()) {
<app-spinner />
} @else if (usersResource.hasValue()) {
<ul>
@for (user of usersResource.value().data; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
<div class="pagination">
<button
[disabled]="page() === 1"
(click)="prevPage()">
Previous
</button>
<span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>
<button
[disabled]="page() >= usersResource.value().totalPages"
(click)="nextPage()">
Next
</button>
</div>
}
</ul>
<div class="pagination">
<button (click)="prevPage()" [disabled]="page() === 1">Previous</button>
<span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>
<button
(click)="nextPage()"
[disabled]="page() >= usersResource.value().totalPages"
>
Next
</button>
</div>
}
`,
`,
})
export class UsersList {
page = signal(1);
pageSize = signal(10);
page = signal(1);
pageSize = signal(10);
usersResource = httpResource<PaginatedResponse<User>>(() => ({
url: "/api/users",
params: {
page: this.page().toString(),
pageSize: this.pageSize().toString(),
},
}));
usersResource = httpResource<PaginatedResponse<User>>(() => ({
url: '/api/users',
params: {
page: this.page().toString(),
pageSize: this.pageSize().toString(),
},
}));
nextPage() {
this.page.update((p) => p + 1);
}
nextPage() {
this.page.update((p) => p + 1);
}
prevPage() {
this.page.update((p) => Math.max(1, p - 1));
}
prevPage() {
this.page.update((p) => Math.max(1, p - 1));
}
}
```
@@ -200,58 +203,58 @@ export class UsersList {
```typescript
@Component({
template: `
<ul>
@for (user of allUsers(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
template: `
<ul>
@for (user of allUsers(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
@if (isLoading()) {
<app-spinner />
}
@if (isLoading()) {
<app-spinner />
}
@if (hasMore()) {
<button (click)="loadMore()">Load More</button>
}
`,
@if (hasMore()) {
<button (click)="loadMore()">Load More</button>
}
`,
})
export class InfiniteUsers {
private http = inject(HttpClient);
private http = inject(HttpClient);
private page = signal(1);
private users = signal<User[]>([]);
private totalPages = signal(1);
private page = signal(1);
private users = signal<User[]>([]);
private totalPages = signal(1);
allUsers = this.users.asReadonly();
isLoading = signal(false);
hasMore = computed(() => this.page() < this.totalPages());
allUsers = this.users.asReadonly();
isLoading = signal(false);
hasMore = computed(() => this.page() < this.totalPages());
constructor() {
this.loadPage(1);
}
loadMore() {
this.loadPage(this.page() + 1);
}
private async loadPage(page: number) {
this.isLoading.set(true);
try {
const response = await firstValueFrom(
this.http.get<PaginatedResponse<User>>("/api/users", {
params: { page: page.toString(), pageSize: "20" },
}),
);
this.users.update((users) => [...users, ...response.data]);
this.page.set(page);
this.totalPages.set(response.totalPages);
} finally {
this.isLoading.set(false);
constructor() {
this.loadPage(1);
}
loadMore() {
this.loadPage(this.page() + 1);
}
private async loadPage(page: number) {
this.isLoading.set(true);
try {
const response = await firstValueFrom(
this.http.get<PaginatedResponse<User>>('/api/users', {
params: { page: page.toString(), pageSize: '20' },
}),
);
this.users.update((users) => [...users, ...response.data]);
this.page.set(page);
this.totalPages.set(response.totalPages);
} finally {
this.isLoading.set(false);
}
}
}
}
```
@@ -261,42 +264,44 @@ export class InfiniteUsers {
```typescript
@Component({
template: `
<input type="file" (change)="onFileSelected($event)" />
template: `
<input
type="file"
(change)="onFileSelected($event)" />
@if (uploadProgress() !== null) {
<progress [value]="uploadProgress()" max="100"></progress>
}
`,
@if (uploadProgress() !== null) {
<progress
max="100"
[value]="uploadProgress()"></progress>
}
`,
})
export class FileUpload {
private http = inject(HttpClient);
private http = inject(HttpClient);
uploadProgress = signal<number | null>(null);
uploadProgress = signal<number | null>(null);
onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
const formData = new FormData();
formData.append('file', file);
this.http
.post("/api/upload", formData, {
reportProgress: true,
observe: "events",
})
.subscribe((event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
this.uploadProgress.set(
Math.round((100 * event.loaded) / event.total),
);
} else if (event.type === HttpEventType.Response) {
this.uploadProgress.set(null);
console.log("Upload complete:", event.body);
}
});
}
this.http
.post('/api/upload', formData, {
reportProgress: true,
observe: 'events',
})
.subscribe((event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
this.uploadProgress.set(Math.round((100 * event.loaded) / event.total));
} else if (event.type === HttpEventType.Response) {
this.uploadProgress.set(null);
console.log('Upload complete:', event.body);
}
});
}
}
```
@@ -321,13 +326,13 @@ uploadFiles(files: FileList) {
```typescript
// resource() automatically handles cancellation via abortSignal
searchResource = resource({
params: () => ({ q: this.query() }),
loader: async ({ params, abortSignal }) => {
const response = await fetch(`/api/search?q=${params.q}`, {
signal: abortSignal, // Cancels if params change
});
return response.json();
},
params: () => ({ q: this.query() }),
loader: async ({ params, abortSignal }) => {
const response = await fetch(`/api/search?q=${params.q}`, {
signal: abortSignal, // Cancels if params change
});
return response.json();
},
});
```
@@ -383,64 +388,64 @@ export class SearchDebounced {
### Testing httpResource
```typescript
describe("UserCmpt", () => {
let component: UserCmpt;
let httpMock: HttpTestingController;
describe('UserCmpt', () => {
let component: UserCmpt;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [provideHttpClientTesting()],
beforeEach(() => {
TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [provideHttpClientTesting()],
});
component = TestBed.createComponent(UserCmpt).componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});
component = TestBed.createComponent(UserCmpt).componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});
it('should load user', () => {
component.userId.set('123');
it("should load user", () => {
component.userId.set("123");
const req = httpMock.expectOne('/api/users/123');
req.flush({ id: '123', name: 'Test User' });
const req = httpMock.expectOne("/api/users/123");
req.flush({ id: "123", name: "Test User" });
expect(component.userResource.value()?.name).toBe('Test User');
});
expect(component.userResource.value()?.name).toBe("Test User");
});
afterEach(() => {
httpMock.verify();
});
afterEach(() => {
httpMock.verify();
});
});
```
### Testing Services
```typescript
describe("User", () => {
let service: User;
let httpMock: HttpTestingController;
describe('User', () => {
let service: User;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [User, provideHttpClient(), provideHttpClientTesting()],
beforeEach(() => {
TestBed.configureTestingModule({
providers: [User, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(User);
httpMock = TestBed.inject(HttpTestingController);
});
service = TestBed.inject(User);
httpMock = TestBed.inject(HttpTestingController);
});
it('should create user', () => {
const newUser = { name: 'Test', email: 'test@example.com' };
it("should create user", () => {
const newUser = { name: "Test", email: "test@example.com" };
service.create(newUser).subscribe((user) => {
expect(user.id).toBeDefined();
expect(user.name).toBe('Test');
});
service.create(newUser).subscribe((user) => {
expect(user.id).toBeDefined();
expect(user.name).toBe("Test");
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush({ id: '1', ...newUser });
});
const req = httpMock.expectOne("/api/users");
expect(req.request.method).toBe("POST");
expect(req.request.body).toEqual(newUser);
req.flush({ id: "1", ...newUser });
});
});
```