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
continuous-integration/drone/pr Build is passing
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user