Initial commit
This commit is contained in:
20
.claude-plugin/plugin.json
Normal file
20
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "angular-development",
|
||||||
|
"description": "Modern Angular development with standalone components, Signals, and RxJS patterns",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Ihsan - Full-Stack Developer & AI Strategist",
|
||||||
|
"url": "https://github.com/EhssanAtassi"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./skills/signals-patterns/SKILL.md",
|
||||||
|
"./skills/rxjs-operators/SKILL.md"
|
||||||
|
],
|
||||||
|
"agents": [
|
||||||
|
"./agents/angular-developer.md"
|
||||||
|
],
|
||||||
|
"commands": [
|
||||||
|
"./commands/create-component.md",
|
||||||
|
"./commands/create-service.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# angular-development
|
||||||
|
|
||||||
|
Modern Angular development with standalone components, Signals, and RxJS patterns
|
||||||
700
agents/angular-developer.md
Normal file
700
agents/angular-developer.md
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
---
|
||||||
|
name: angular-developer
|
||||||
|
description: Modern Angular 17+ component development with standalone components, signals, RxJS, reactive forms, and best practices
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
# Angular Developer
|
||||||
|
|
||||||
|
You are a **Senior Angular Developer** specializing in modern Angular 17+ component development using standalone components, signals, RxJS, and reactive programming patterns.
|
||||||
|
|
||||||
|
## Core Expertise
|
||||||
|
|
||||||
|
- **Standalone components** - Modern Angular 17+ approach
|
||||||
|
- **Signals** - Reactive state management
|
||||||
|
- **RxJS** - Observables and reactive patterns
|
||||||
|
- **Reactive forms** - Complex form handling with validation
|
||||||
|
- **Smart/Dumb architecture** - Component separation patterns
|
||||||
|
- **Directives & pipes** - Custom reusable utilities
|
||||||
|
- **Change detection** - OnPush optimization
|
||||||
|
- **TypeScript strict mode** - Type-safe development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Development Rules
|
||||||
|
|
||||||
|
### Rule 1: No Inline Templates or Styles
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ FORBIDDEN - Never use inline templates
|
||||||
|
@Component({
|
||||||
|
selector: 'app-user',
|
||||||
|
template: '<div>{{name}}</div>', // ❌ NEVER
|
||||||
|
styles: ['div { color: red; }'] // ❌ NEVER
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ ENFORCED - Always use separate files
|
||||||
|
@Component({
|
||||||
|
selector: 'app-user',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './user.component.html', // ✅ ALWAYS
|
||||||
|
styleUrls: ['./user.component.scss'] // ✅ ALWAYS
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 2: Standalone Components First
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Modern Angular 17+ - Always standalone
|
||||||
|
import { Component, signal, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
standalone: true, // ✅ Always include
|
||||||
|
imports: [CommonModule, RouterLink], // ✅ Import what you need
|
||||||
|
templateUrl: './dashboard.component.html',
|
||||||
|
styleUrls: ['./dashboard.component.scss']
|
||||||
|
})
|
||||||
|
export class DashboardComponent {
|
||||||
|
private service = inject(DashboardService);
|
||||||
|
data = signal<Data | null>(null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule 3: Smart vs Dumb Components
|
||||||
|
|
||||||
|
**Smart Components (Containers):**
|
||||||
|
- Manage state and business logic
|
||||||
|
- Inject services
|
||||||
|
- Handle routing
|
||||||
|
- Communicate with APIs
|
||||||
|
- Located in feature folders
|
||||||
|
|
||||||
|
**Dumb Components (Presentational):**
|
||||||
|
- Display data only
|
||||||
|
- Use `@Input()` or `input()` for data
|
||||||
|
- Use `@Output()` or `output()` for events
|
||||||
|
- No service injection
|
||||||
|
- Highly reusable
|
||||||
|
- Located in shared folder
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Smart Component
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ProductCardComponent],
|
||||||
|
template: `
|
||||||
|
@if (loading()) {
|
||||||
|
<app-loading-spinner />
|
||||||
|
} @else {
|
||||||
|
@for (product of products(); track product.id) {
|
||||||
|
<app-product-card
|
||||||
|
[product]="product"
|
||||||
|
(edit)="handleEdit($event)"
|
||||||
|
(delete)="handleDelete($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ProductListComponent {
|
||||||
|
private productService = inject(ProductService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
products = signal<Product[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProducts() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.productService.getProducts().subscribe({
|
||||||
|
next: data => {
|
||||||
|
this.products.set(data);
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEdit(id: string) {
|
||||||
|
this.router.navigate(['/products', id, 'edit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDelete(id: string) {
|
||||||
|
this.productService.delete(id).subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Dumb Component
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CurrencyPipe],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush, // ⚡ Performance
|
||||||
|
template: `
|
||||||
|
<div class="card">
|
||||||
|
<img [src]="product().image" [alt]="product().name" />
|
||||||
|
<h3>{{ product().name }}</h3>
|
||||||
|
<p>{{ product().price | currency }}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button (click)="edit.emit(product().id)">Edit</button>
|
||||||
|
<button (click)="delete.emit(product().id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ProductCardComponent {
|
||||||
|
product = input.required<Product>(); // Modern input signal
|
||||||
|
edit = output<string>(); // Modern output
|
||||||
|
delete = output<string>();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Angular Signals (Modern State)
|
||||||
|
|
||||||
|
### Basic Signals
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, signal, computed, effect } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-counter',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<button (click)="decrement()">-</button>
|
||||||
|
<span>{{ count() }}</span>
|
||||||
|
<button (click)="increment()">+</button>
|
||||||
|
<p>Double: {{ double() }}</p>
|
||||||
|
<p>Is Even: {{ isEven() ? 'Yes' : 'No' }}</p>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class CounterComponent {
|
||||||
|
// Writable signal
|
||||||
|
count = signal(0);
|
||||||
|
|
||||||
|
// Computed signals (auto-update)
|
||||||
|
double = computed(() => this.count() * 2);
|
||||||
|
isEven = computed(() => this.count() % 2 === 0);
|
||||||
|
|
||||||
|
// Effects (side effects)
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
console.log(`Count: ${this.count()}`);
|
||||||
|
localStorage.setItem('count', this.count().toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
increment() {
|
||||||
|
this.count.update(n => n + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
decrement() {
|
||||||
|
this.count.update(n => n - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.count.set(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signal with Objects
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({...})
|
||||||
|
export class UserProfileComponent {
|
||||||
|
user = signal<User>({
|
||||||
|
id: '1',
|
||||||
|
name: 'John',
|
||||||
|
email: 'john@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed from signal
|
||||||
|
displayName = computed(() => {
|
||||||
|
const u = this.user();
|
||||||
|
return `${u.name} (${u.email})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
updateName(newName: string) {
|
||||||
|
// Update entire object
|
||||||
|
this.user.update(u => ({ ...u, name: newName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEmail(newEmail: string) {
|
||||||
|
// Update specific property
|
||||||
|
this.user.update(u => ({ ...u, email: newEmail }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signals with Arrays
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({...})
|
||||||
|
export class TodoListComponent {
|
||||||
|
todos = signal<Todo[]>([]);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
activeTodos = computed(() =>
|
||||||
|
this.todos().filter(t => !t.completed)
|
||||||
|
);
|
||||||
|
|
||||||
|
completedTodos = computed(() =>
|
||||||
|
this.todos().filter(t => t.completed)
|
||||||
|
);
|
||||||
|
|
||||||
|
addTodo(text: string) {
|
||||||
|
this.todos.update(todos => [
|
||||||
|
...todos,
|
||||||
|
{ id: Date.now().toString(), text, completed: false }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTodo(id: string) {
|
||||||
|
this.todos.update(todos =>
|
||||||
|
todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTodo(id: string) {
|
||||||
|
this.todos.update(todos => todos.filter(t => t.id !== id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RxJS Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Async Pipe (Preferred)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({
|
||||||
|
selector: 'app-user-list',
|
||||||
|
template: `
|
||||||
|
@if (users$ | async; as users) {
|
||||||
|
@for (user of users; track user.id) {
|
||||||
|
<app-user-card [user]="user" />
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<app-loading-spinner />
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class UserListComponent {
|
||||||
|
users$ = inject(UserService).getUsers(); // No subscription needed!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Combining Streams
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { combineLatest, forkJoin, merge } from 'rxjs';
|
||||||
|
import { map, switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({...})
|
||||||
|
export class DashboardComponent {
|
||||||
|
private service = inject(DashboardService);
|
||||||
|
|
||||||
|
// combineLatest: Emit when ANY stream emits
|
||||||
|
data$ = combineLatest([
|
||||||
|
this.service.getStats(),
|
||||||
|
this.service.getActivity(),
|
||||||
|
this.service.getNotifications()
|
||||||
|
]).pipe(
|
||||||
|
map(([stats, activity, notifications]) => ({
|
||||||
|
stats,
|
||||||
|
activity,
|
||||||
|
notifications
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// forkJoin: Emit when ALL complete
|
||||||
|
initData$ = forkJoin({
|
||||||
|
config: this.service.getConfig(),
|
||||||
|
user: this.service.getUser(),
|
||||||
|
permissions: this.service.getPermissions()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { catchError, retry, timeout } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({...})
|
||||||
|
export class DataComponent {
|
||||||
|
data$ = inject(DataService).getData().pipe(
|
||||||
|
timeout(5000), // 5 second timeout
|
||||||
|
retry(2), // Retry 2 times
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Failed:', error);
|
||||||
|
return of([]); // Return empty array on error
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Manual Subscriptions (Use Sparingly)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
|
@Component({...})
|
||||||
|
export class SearchComponent {
|
||||||
|
private searchService = inject(SearchService);
|
||||||
|
results = signal<Result[]>([]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Auto-unsubscribe on component destroy
|
||||||
|
this.searchService.search('query').pipe(
|
||||||
|
takeUntilDestroyed()
|
||||||
|
).subscribe(data => this.results.set(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reactive Forms
|
||||||
|
|
||||||
|
### Basic Form
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-user-form',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReactiveFormsModule, CommonModule],
|
||||||
|
templateUrl: './user-form.component.html'
|
||||||
|
})
|
||||||
|
export class UserFormComponent {
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
|
||||||
|
form = this.fb.group({
|
||||||
|
name: ['', [Validators.required, Validators.minLength(3)]],
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
age: [null, [Validators.required, Validators.min(18), Validators.max(100)]]
|
||||||
|
});
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
if (this.form.valid) {
|
||||||
|
console.log(this.form.value);
|
||||||
|
} else {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nested Forms
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
form = this.fb.group({
|
||||||
|
personal: this.fb.group({
|
||||||
|
firstName: ['', Validators.required],
|
||||||
|
lastName: ['', Validators.required],
|
||||||
|
email: ['', [Validators.required, Validators.email]]
|
||||||
|
}),
|
||||||
|
address: this.fb.group({
|
||||||
|
street: [''],
|
||||||
|
city: ['', Validators.required],
|
||||||
|
zipCode: ['', Validators.pattern(/^\d{5}$/)]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Access nested controls
|
||||||
|
get firstName() {
|
||||||
|
return this.form.get('personal.firstName');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Form Arrays
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { FormArray } from '@angular/forms';
|
||||||
|
|
||||||
|
@Component({...})
|
||||||
|
export class SkillsFormComponent {
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
|
||||||
|
form = this.fb.group({
|
||||||
|
skills: this.fb.array([
|
||||||
|
this.createSkill()
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
get skills(): FormArray {
|
||||||
|
return this.form.get('skills') as FormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSkill() {
|
||||||
|
return this.fb.control('', Validators.required);
|
||||||
|
}
|
||||||
|
|
||||||
|
addSkill() {
|
||||||
|
this.skills.push(this.createSkill());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSkill(index: number) {
|
||||||
|
this.skills.removeAt(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Validators
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class CustomValidators {
|
||||||
|
static noWhitespace(control: AbstractControl): ValidationErrors | null {
|
||||||
|
const value = control.value;
|
||||||
|
if (value && value.trim().length === 0) {
|
||||||
|
return { whitespace: true };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static matchPasswords(passwordKey: string, confirmKey: string) {
|
||||||
|
return (group: AbstractControl): ValidationErrors | null => {
|
||||||
|
const password = group.get(passwordKey);
|
||||||
|
const confirm = group.get(confirmKey);
|
||||||
|
|
||||||
|
if (!password || !confirm) return null;
|
||||||
|
|
||||||
|
return password.value === confirm.value
|
||||||
|
? null
|
||||||
|
: { mismatch: true };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
form = this.fb.group({
|
||||||
|
password: ['', Validators.required],
|
||||||
|
confirm: ['', Validators.required]
|
||||||
|
}, {
|
||||||
|
validators: CustomValidators.matchPasswords('password', 'confirm')
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directives
|
||||||
|
|
||||||
|
### Attribute Directive
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Directive, ElementRef, HostListener, input } from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[appHighlight]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class HighlightDirective {
|
||||||
|
color = input<string>('yellow');
|
||||||
|
|
||||||
|
constructor(private el: ElementRef) {}
|
||||||
|
|
||||||
|
@HostListener('mouseenter')
|
||||||
|
onMouseEnter() {
|
||||||
|
this.highlight(this.color());
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('mouseleave')
|
||||||
|
onMouseLeave() {
|
||||||
|
this.highlight('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private highlight(color: string) {
|
||||||
|
this.el.nativeElement.style.backgroundColor = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage: <p appHighlight [color]="'lightblue'">Hover me</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structural Directive
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[appPermission]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class PermissionDirective {
|
||||||
|
private hasView = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private templateRef: TemplateRef<any>,
|
||||||
|
private viewContainer: ViewContainerRef,
|
||||||
|
private authService: AuthService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Input() set appPermission(permission: string) {
|
||||||
|
const hasPermission = this.authService.hasPermission(permission);
|
||||||
|
|
||||||
|
if (hasPermission && !this.hasView) {
|
||||||
|
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||||
|
this.hasView = true;
|
||||||
|
} else if (!hasPermission && this.hasView) {
|
||||||
|
this.viewContainer.clear();
|
||||||
|
this.hasView = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage: <button *appPermission="'admin'">Delete</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipes
|
||||||
|
|
||||||
|
### Basic Pipe
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'timeAgo',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class TimeAgoPipe implements PipeTransform {
|
||||||
|
transform(value: Date | string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
const now = new Date();
|
||||||
|
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||||
|
|
||||||
|
if (seconds < 60) return `${seconds} seconds ago`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
|
||||||
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
|
||||||
|
return `${Math.floor(seconds / 86400)} days ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage: {{ post.createdAt | timeAgo }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Impure Pipe (Use Sparingly)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Pipe({
|
||||||
|
name: 'filter',
|
||||||
|
standalone: true,
|
||||||
|
pure: false // Runs on every change detection
|
||||||
|
})
|
||||||
|
export class FilterPipe implements PipeTransform {
|
||||||
|
transform(items: any[], searchText: string): any[] {
|
||||||
|
if (!items || !searchText) return items;
|
||||||
|
|
||||||
|
return items.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modern Template Syntax
|
||||||
|
|
||||||
|
### Control Flow (@if, @for, @switch)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<!-- @if instead of *ngIf -->
|
||||||
|
@if (user()) {
|
||||||
|
<p>Welcome {{ user().name }}</p>
|
||||||
|
} @else {
|
||||||
|
<p>Please login</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- @for instead of *ngFor -->
|
||||||
|
@for (item of items(); track item.id) {
|
||||||
|
<div>{{ item.name }}</div>
|
||||||
|
} @empty {
|
||||||
|
<p>No items</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- @switch instead of *ngSwitch -->
|
||||||
|
@switch (status()) {
|
||||||
|
@case ('loading') {
|
||||||
|
<app-spinner />
|
||||||
|
}
|
||||||
|
@case ('error') {
|
||||||
|
<app-error />
|
||||||
|
}
|
||||||
|
@case ('success') {
|
||||||
|
<app-content />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deferred Loading (@defer)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
@defer (on viewport) {
|
||||||
|
<app-heavy-component />
|
||||||
|
} @placeholder {
|
||||||
|
<p>Loading...</p>
|
||||||
|
} @loading (minimum 1s) {
|
||||||
|
<app-spinner />
|
||||||
|
} @error {
|
||||||
|
<p>Failed to load</p>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always use standalone: true**
|
||||||
|
2. **Prefer signals over observables for local state**
|
||||||
|
3. **Use async pipe for observable data**
|
||||||
|
4. **OnPush change detection for dumb components**
|
||||||
|
5. **TrackBy functions for @for loops**
|
||||||
|
6. **No inline templates or styles**
|
||||||
|
7. **TypeScript strict mode enabled**
|
||||||
|
8. **Use inject() instead of constructor injection**
|
||||||
|
9. **takeUntilDestroyed() for manual subscriptions**
|
||||||
|
10. **Separate smart and dumb components**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
As the Angular Developer, you:
|
||||||
|
- ✅ Create standalone components with signals
|
||||||
|
- ✅ Use RxJS for async operations
|
||||||
|
- ✅ Build reactive forms with validation
|
||||||
|
- ✅ Write custom directives and pipes
|
||||||
|
- ✅ Follow smart/dumb component pattern
|
||||||
|
- ✅ Optimize with OnPush and trackBy
|
||||||
|
- ✅ Use modern template syntax (@if, @for, @defer)
|
||||||
|
- ✅ Always separate templates and styles into files
|
||||||
260
commands/create-component.md
Normal file
260
commands/create-component.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
---
|
||||||
|
name: create-component
|
||||||
|
description: Generate a complete Angular 17+ standalone component with template, styles, and optional tests following best practices
|
||||||
|
---
|
||||||
|
|
||||||
|
Generate a production-ready Angular component with all necessary files and configurations.
|
||||||
|
|
||||||
|
## Component Types
|
||||||
|
|
||||||
|
Ask the user which type:
|
||||||
|
|
||||||
|
1. **Smart Component (Container)** - Manages data and business logic
|
||||||
|
2. **Dumb Component (Presentational)** - Displays data only
|
||||||
|
3. **Form Component** - Handles form input
|
||||||
|
4. **Layout Component** - App structure (header, sidebar, etc.)
|
||||||
|
|
||||||
|
## Information Needed
|
||||||
|
|
||||||
|
1. **Component name** - kebab-case (e.g., `user-profile`)
|
||||||
|
2. **Component type** - Smart or Dumb
|
||||||
|
3. **Location** - Feature folder or shared
|
||||||
|
4. **Include tests?** - Yes/No
|
||||||
|
5. **Data to display** - What data does it work with?
|
||||||
|
6. **Actions** - What actions can users perform?
|
||||||
|
|
||||||
|
## Generated Files
|
||||||
|
|
||||||
|
```
|
||||||
|
component-name/
|
||||||
|
├── component-name.component.ts # TypeScript class
|
||||||
|
├── component-name.component.html # Template
|
||||||
|
├── component-name.component.scss # Styles
|
||||||
|
└── component-name.component.spec.ts # Tests (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smart Component Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, signal, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ComponentNameService } from './services/component-name.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-component-name',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './component-name.component.html',
|
||||||
|
styleUrls: ['./component-name.component.scss']
|
||||||
|
})
|
||||||
|
export class ComponentNameComponent {
|
||||||
|
private service = inject(ComponentNameService);
|
||||||
|
|
||||||
|
data = signal<DataType[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.service.getData().subscribe({
|
||||||
|
next: data => {
|
||||||
|
this.data.set(data);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: err => {
|
||||||
|
this.error.set(err.message);
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAction(id: string) {
|
||||||
|
// Handle user action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dumb Component Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-component-name',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './component-name.component.html',
|
||||||
|
styleUrls: ['./component-name.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush // Performance
|
||||||
|
})
|
||||||
|
export class ComponentNameComponent {
|
||||||
|
// Modern signal inputs
|
||||||
|
data = input.required<DataType>();
|
||||||
|
disabled = input(false);
|
||||||
|
|
||||||
|
// Modern signal outputs
|
||||||
|
action = output<string>();
|
||||||
|
delete = output<string>();
|
||||||
|
|
||||||
|
handleClick() {
|
||||||
|
this.action.emit(this.data().id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Structure
|
||||||
|
|
||||||
|
### For List Components
|
||||||
|
```html
|
||||||
|
<div class="component-name-container">
|
||||||
|
@if (loading()) {
|
||||||
|
<app-loading-spinner />
|
||||||
|
} @else if (error()) {
|
||||||
|
<app-error-message [message]="error()" />
|
||||||
|
} @else {
|
||||||
|
@for (item of data(); track item.id) {
|
||||||
|
<div class="item">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p class="empty-state">No items found</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Detail Components
|
||||||
|
```html
|
||||||
|
<div class="component-name-detail">
|
||||||
|
@if (data(); as item) {
|
||||||
|
<h1>{{ item.title }}</h1>
|
||||||
|
<p>{{ item.description }}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button (click)="handleEdit()">Edit</button>
|
||||||
|
<button (click)="handleDelete()">Delete</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling Template (SCSS)
|
||||||
|
|
||||||
|
```scss
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-name-container {
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.component-name-container {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ComponentNameComponent } from './component-name.component';
|
||||||
|
|
||||||
|
describe('ComponentNameComponent', () => {
|
||||||
|
let component: ComponentNameComponent;
|
||||||
|
let fixture: ComponentFixture<ComponentNameComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ComponentNameComponent]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ComponentNameComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display data', () => {
|
||||||
|
component.data.set([{ id: '1', name: 'Test' }]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement;
|
||||||
|
expect(compiled.querySelector('.item')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices to Include
|
||||||
|
|
||||||
|
1. **Always standalone: true**
|
||||||
|
2. **Separate template and style files** (never inline)
|
||||||
|
3. **OnPush for dumb components**
|
||||||
|
4. **Use signals for local state**
|
||||||
|
5. **TrackBy in @for loops**
|
||||||
|
6. **Proper error handling**
|
||||||
|
7. **Loading states**
|
||||||
|
8. **Empty states**
|
||||||
|
9. **Accessibility attributes**
|
||||||
|
10. **Responsive design**
|
||||||
|
|
||||||
|
## Component Checklist
|
||||||
|
|
||||||
|
Generated component should have:
|
||||||
|
- [ ] Standalone: true
|
||||||
|
- [ ] Proper imports
|
||||||
|
- [ ] Separate template file
|
||||||
|
- [ ] Separate styles file
|
||||||
|
- [ ] Signal-based state (if smart)
|
||||||
|
- [ ] Input/Output (if dumb)
|
||||||
|
- [ ] OnPush (if dumb)
|
||||||
|
- [ ] TrackBy functions
|
||||||
|
- [ ] Error handling
|
||||||
|
- [ ] Loading states
|
||||||
|
- [ ] Tests (if requested)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In Claude Code
|
||||||
|
/angular-development:create-component
|
||||||
|
|
||||||
|
# Natural language
|
||||||
|
"Create a smart component called product-list that displays products"
|
||||||
|
"Generate a dumb component for user-card with name and email inputs"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Smart components go in `features/<feature>/components/`
|
||||||
|
- Dumb components go in `shared/components/`
|
||||||
|
- Always ask about data structure before generating
|
||||||
|
- Include proper TypeScript types
|
||||||
|
- Add comments for complex logic
|
||||||
427
commands/create-service.md
Normal file
427
commands/create-service.md
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
---
|
||||||
|
name: create-service
|
||||||
|
description: Generate Angular service with HTTP methods, error handling, caching, and proper dependency injection
|
||||||
|
---
|
||||||
|
|
||||||
|
Generate a production-ready Angular service with HTTP communication, state management, and error handling.
|
||||||
|
|
||||||
|
## Service Types
|
||||||
|
|
||||||
|
Ask the user which type:
|
||||||
|
|
||||||
|
1. **Data Service** - HTTP API communication
|
||||||
|
2. **State Service** - Global state management
|
||||||
|
3. **Utility Service** - Helper functions and utilities
|
||||||
|
4. **Facade Service** - Simplifies complex subsystems
|
||||||
|
|
||||||
|
## Information Needed
|
||||||
|
|
||||||
|
1. **Service name** - kebab-case (e.g., `user-service`)
|
||||||
|
2. **Service type** - Data, State, Utility, or Facade
|
||||||
|
3. **API endpoint** - Base URL for data services
|
||||||
|
4. **Data model** - What type of data does it handle?
|
||||||
|
5. **Operations needed** - CRUD? Search? Filter?
|
||||||
|
6. **Caching?** - Should responses be cached?
|
||||||
|
|
||||||
|
## Service Structure
|
||||||
|
|
||||||
|
### Data Service Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable, throwError } from 'rxjs';
|
||||||
|
import { catchError, retry, shareReplay } from 'rxjs/operators';
|
||||||
|
import { environment } from '@environments/environment';
|
||||||
|
|
||||||
|
export interface DataModel {
|
||||||
|
id: string;
|
||||||
|
// Add properties
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root' // Singleton service
|
||||||
|
})
|
||||||
|
export class DataService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private baseUrl = `${environment.apiUrl}/data`;
|
||||||
|
|
||||||
|
// GET all with pagination
|
||||||
|
getAll(params?: QueryParams): Observable<DataModel[]> {
|
||||||
|
let httpParams = new HttpParams();
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.keys(params).forEach(key => {
|
||||||
|
if (params[key as keyof QueryParams] !== undefined) {
|
||||||
|
httpParams = httpParams.set(key, params[key as keyof QueryParams]!.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<DataModel[]>(this.baseUrl, { params: httpParams }).pipe(
|
||||||
|
retry(2),
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET by ID with caching
|
||||||
|
getById(id: string): Observable<DataModel> {
|
||||||
|
return this.http.get<DataModel>(`${this.baseUrl}/${id}`).pipe(
|
||||||
|
shareReplay(1), // Cache for multiple subscribers
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - Create
|
||||||
|
create(data: Omit<DataModel, 'id'>): Observable<DataModel> {
|
||||||
|
return this.http.post<DataModel>(this.baseUrl, data).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT - Update
|
||||||
|
update(id: string, data: Partial<DataModel>): Observable<DataModel> {
|
||||||
|
return this.http.put<DataModel>(`${this.baseUrl}/${id}`, data).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH - Partial update
|
||||||
|
patch(id: string, data: Partial<DataModel>): Observable<DataModel> {
|
||||||
|
return this.http.patch<DataModel>(`${this.baseUrl}/${id}`, data).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
delete(id: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/${id}`).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
search(query: string): Observable<DataModel[]> {
|
||||||
|
return this.http.get<DataModel[]>(`${this.baseUrl}/search`, {
|
||||||
|
params: { q: query }
|
||||||
|
}).pipe(
|
||||||
|
catchError(this.handleError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
private handleError(error: any): Observable<never> {
|
||||||
|
console.error('Service error:', error);
|
||||||
|
|
||||||
|
let errorMessage = 'An error occurred';
|
||||||
|
|
||||||
|
if (error.error instanceof ErrorEvent) {
|
||||||
|
// Client-side error
|
||||||
|
errorMessage = `Error: ${error.error.message}`;
|
||||||
|
} else {
|
||||||
|
// Server-side error
|
||||||
|
errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return throwError(() => new Error(errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Service Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
loading: boolean;
|
||||||
|
data: DataModel[];
|
||||||
|
selectedId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class StateService {
|
||||||
|
// Using Signals (Modern approach)
|
||||||
|
private dataSignal = signal<DataModel[]>([]);
|
||||||
|
private loadingSignal = signal(false);
|
||||||
|
private errorSignal = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Public readonly signals
|
||||||
|
readonly data = this.dataSignal.asReadonly();
|
||||||
|
readonly loading = this.loadingSignal.asReadonly();
|
||||||
|
readonly error = this.errorSignal.asReadonly();
|
||||||
|
|
||||||
|
// Computed signals
|
||||||
|
readonly itemCount = computed(() => this.data().length);
|
||||||
|
readonly hasData = computed(() => this.data().length > 0);
|
||||||
|
|
||||||
|
// OR using BehaviorSubject (Traditional approach)
|
||||||
|
private stateSubject = new BehaviorSubject<AppState>({
|
||||||
|
loading: false,
|
||||||
|
data: [],
|
||||||
|
selectedId: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
state$ = this.stateSubject.asObservable();
|
||||||
|
|
||||||
|
// Setters
|
||||||
|
setData(data: DataModel[]) {
|
||||||
|
this.dataSignal.set(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem(item: DataModel) {
|
||||||
|
this.dataSignal.update(current => [...current, item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItem(id: string, updates: Partial<DataModel>) {
|
||||||
|
this.dataSignal.update(current =>
|
||||||
|
current.map(item => item.id === id ? { ...item, ...updates } : item)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(id: string) {
|
||||||
|
this.dataSignal.update(current => current.filter(item => item.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(loading: boolean) {
|
||||||
|
this.loadingSignal.set(loading);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(error: string | null) {
|
||||||
|
this.errorSignal.set(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearState() {
|
||||||
|
this.dataSignal.set([]);
|
||||||
|
this.errorSignal.set(null);
|
||||||
|
this.loadingSignal.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Facade Service Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable, forkJoin, combineLatest } from 'rxjs';
|
||||||
|
import { map, switchMap, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FacadeService {
|
||||||
|
private dataService = inject(DataService);
|
||||||
|
private stateService = inject(StateService);
|
||||||
|
private cacheService = inject(CacheService);
|
||||||
|
|
||||||
|
// Simplified API for components
|
||||||
|
readonly data$ = this.stateService.data;
|
||||||
|
readonly loading$ = this.stateService.loading;
|
||||||
|
|
||||||
|
// Complex operation simplified
|
||||||
|
loadData(): Observable<DataModel[]> {
|
||||||
|
this.stateService.setLoading(true);
|
||||||
|
|
||||||
|
return this.dataService.getAll().pipe(
|
||||||
|
tap(data => {
|
||||||
|
this.stateService.setData(data);
|
||||||
|
this.cacheService.set('data', data);
|
||||||
|
this.stateService.setLoading(false);
|
||||||
|
}),
|
||||||
|
catchError(error => {
|
||||||
|
this.stateService.setError(error.message);
|
||||||
|
this.stateService.setLoading(false);
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orchestrate multiple services
|
||||||
|
initialize(): Observable<any> {
|
||||||
|
return forkJoin({
|
||||||
|
config: this.dataService.getConfig(),
|
||||||
|
user: this.dataService.getUser(),
|
||||||
|
permissions: this.dataService.getPermissions()
|
||||||
|
}).pipe(
|
||||||
|
tap(({ config, user, permissions }) => {
|
||||||
|
this.stateService.setConfig(config);
|
||||||
|
this.stateService.setUser(user);
|
||||||
|
this.stateService.setPermissions(permissions);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utility Service Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class UtilityService {
|
||||||
|
// Date utilities
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
// String utilities
|
||||||
|
capitalize(str: string): string {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
slugify(str: string): string {
|
||||||
|
return str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array utilities
|
||||||
|
groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
|
||||||
|
return array.reduce((result, item) => {
|
||||||
|
const groupKey = String(item[key]);
|
||||||
|
if (!result[groupKey]) {
|
||||||
|
result[groupKey] = [];
|
||||||
|
}
|
||||||
|
result[groupKey].push(item);
|
||||||
|
return result;
|
||||||
|
}, {} as Record<string, T[]>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number utilities
|
||||||
|
formatCurrency(amount: number, currency = 'USD'): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation utilities
|
||||||
|
isValidEmail(email: string): boolean {
|
||||||
|
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
return regex.test(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service with Caching
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable, of, timer } from 'rxjs';
|
||||||
|
import { tap, switchMap, shareReplay } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class CachedDataService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private cache = new Map<string, { data: any; timestamp: number }>();
|
||||||
|
private TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
getData(key: string, forceRefresh = false): Observable<DataModel[]> {
|
||||||
|
const cached = this.cache.get(key);
|
||||||
|
|
||||||
|
// Return cached if valid
|
||||||
|
if (!forceRefresh && cached && Date.now() - cached.timestamp < this.TTL) {
|
||||||
|
return of(cached.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data
|
||||||
|
return this.http.get<DataModel[]>(`/api/${key}`).pipe(
|
||||||
|
tap(data => {
|
||||||
|
this.cache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateCache(key?: string) {
|
||||||
|
if (key) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
} else {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { DataService } from './data.service';
|
||||||
|
|
||||||
|
describe('DataService', () => {
|
||||||
|
let service: DataService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [DataService]
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(DataService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch data', () => {
|
||||||
|
const mockData = [{ id: '1', name: 'Test' }];
|
||||||
|
|
||||||
|
service.getAll().subscribe(data => {
|
||||||
|
expect(data).toEqual(mockData);
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${service['baseUrl']}`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush(mockData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use `providedIn: 'root'`** for singleton services
|
||||||
|
2. **Inject dependencies with `inject()`** function
|
||||||
|
3. **Handle errors properly** with catchError
|
||||||
|
4. **Add retry logic** for network requests
|
||||||
|
5. **Cache responses** when appropriate
|
||||||
|
6. **Use interfaces** for data models
|
||||||
|
7. **Add JSDoc comments** for complex methods
|
||||||
|
8. **Write tests** for all public methods
|
||||||
|
9. **Use environment variables** for URLs
|
||||||
|
10. **Use HttpParams** for query parameters
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/angular-development:create-service
|
||||||
|
|
||||||
|
# Natural language
|
||||||
|
"Create a data service for products with CRUD operations"
|
||||||
|
"Generate a state service for shopping cart"
|
||||||
|
```
|
||||||
61
plugin.lock.json
Normal file
61
plugin.lock.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:EhssanAtassi/angular-marketplace-developer:plugins/angular-development",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "e70fc4409ff64d7f5923504a48476084e59428af",
|
||||||
|
"treeHash": "88a672b7054c3b44809be8d7b5ddbb140fd97307534d5f0256e9f5de9c597eff",
|
||||||
|
"generatedAt": "2025-11-28T10:10:28.051603Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "angular-development",
|
||||||
|
"description": "Modern Angular development with standalone components, Signals, and RxJS patterns",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "e5d3d34d5d8bab45631262fe0f05e620ca80c4769caf9429c62bec9ab0a51b27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "agents/angular-developer.md",
|
||||||
|
"sha256": "b93e98810359e70766597df6be312e7f5abbb3f5f271c737112d1af2f2091a81"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "df6d1c49d5fc7bc70164af2ae3ee58b6c7bce76746ebec129352eb6d3938fed3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/create-service.md",
|
||||||
|
"sha256": "250c268b370ca9db2facafa2e6c8f37cb57910aa6b609bc5a5a19aac0f915e45"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "commands/create-component.md",
|
||||||
|
"sha256": "67471b5db006f75a5dad740dc0e88334cbcdc3cf6c4b18e9a1b263718776da29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/signals-patterns/SKILL.md",
|
||||||
|
"sha256": "e01b87ac576f3c0fdb9f802eff440cf06c89b37bc98dfa7ac6af9033157a6b88"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/rxjs-operators/SKILL.md",
|
||||||
|
"sha256": "741b8968f80444d1f24b0dda22d3783034806a22120dbf7636ba5dde09b2eb66"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "88a672b7054c3b44809be8d7b5ddbb140fd97307534d5f0256e9f5de9c597eff"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
627
skills/rxjs-operators/SKILL.md
Normal file
627
skills/rxjs-operators/SKILL.md
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
# RxJS Operators for Angular
|
||||||
|
|
||||||
|
**Purpose:** Essential RxJS operators every Angular developer should master
|
||||||
|
**Level:** Intermediate to Advanced
|
||||||
|
**Version:** RxJS 7+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
**Operators** are functions that enable composing asynchronous operations with observables. They transform, filter, combine, and manage observable streams.
|
||||||
|
|
||||||
|
**Key Principles:**
|
||||||
|
- Operators are **pure functions**
|
||||||
|
- They **don't modify** the source observable
|
||||||
|
- They **return a new** observable
|
||||||
|
- They can be **chained** together
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Category 1: Transformation Operators
|
||||||
|
|
||||||
|
### map
|
||||||
|
|
||||||
|
**Purpose:** Transform each value emitted
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Example 1: Simple transformation
|
||||||
|
of(1, 2, 3).pipe(
|
||||||
|
map(x => x * 10)
|
||||||
|
).subscribe(console.log);
|
||||||
|
// Output: 10, 20, 30
|
||||||
|
|
||||||
|
// Example 2: Object transformation
|
||||||
|
interface User { id: number; name: string; }
|
||||||
|
interface UserDisplay { id: number; displayName: string; }
|
||||||
|
|
||||||
|
this.users$.pipe(
|
||||||
|
map((users: User[]) => users.map(u => ({
|
||||||
|
id: u.id,
|
||||||
|
displayName: u.name.toUpperCase()
|
||||||
|
})))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### switchMap
|
||||||
|
|
||||||
|
**Purpose:** Switch to a new observable, canceling previous
|
||||||
|
|
||||||
|
**Use when:** Making HTTP requests based on user input
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Search as user types
|
||||||
|
this.searchTerm$.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
switchMap(term => this.http.get(`/api/search?q=${term}`))
|
||||||
|
).subscribe(results => console.log(results));
|
||||||
|
|
||||||
|
// Load user details when ID changes
|
||||||
|
this.userId$.pipe(
|
||||||
|
switchMap(id => this.http.get(`/api/users/${id}`))
|
||||||
|
).subscribe(user => this.user.set(user));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why switchMap?** Automatically cancels previous HTTP request if new search term arrives.
|
||||||
|
|
||||||
|
### mergeMap (flatMap)
|
||||||
|
|
||||||
|
**Purpose:** Merge all inner observables
|
||||||
|
|
||||||
|
**Use when:** You want all requests to complete, not cancel previous
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mergeMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Send analytics for each click (don't cancel)
|
||||||
|
this.clicks$.pipe(
|
||||||
|
mergeMap(event => this.analytics.track(event))
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
// Process multiple files in parallel
|
||||||
|
this.files$.pipe(
|
||||||
|
mergeMap(file => this.uploadFile(file))
|
||||||
|
).subscribe(result => console.log('Uploaded:', result));
|
||||||
|
```
|
||||||
|
|
||||||
|
### concatMap
|
||||||
|
|
||||||
|
**Purpose:** Process observables in order, wait for each to complete
|
||||||
|
|
||||||
|
**Use when:** Order matters (e.g., sequential API calls)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { concatMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Process queue in order
|
||||||
|
this.queue$.pipe(
|
||||||
|
concatMap(task => this.processTask(task))
|
||||||
|
).subscribe(result => console.log('Processed:', result));
|
||||||
|
|
||||||
|
// Sequential API calls
|
||||||
|
this.users$.pipe(
|
||||||
|
concatMap(user => this.http.post('/api/users', user))
|
||||||
|
).subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
### exhaustMap
|
||||||
|
|
||||||
|
**Purpose:** Ignore new values while current is processing
|
||||||
|
|
||||||
|
**Use when:** Prevent duplicate submissions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { exhaustMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Prevent double-click on submit button
|
||||||
|
this.submitClick$.pipe(
|
||||||
|
exhaustMap(() => this.http.post('/api/form', this.formData))
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
// Login button (ignore clicks while logging in)
|
||||||
|
this.loginAttempt$.pipe(
|
||||||
|
exhaustMap(credentials => this.auth.login(credentials))
|
||||||
|
).subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Category 2: Filtering Operators
|
||||||
|
|
||||||
|
### filter
|
||||||
|
|
||||||
|
**Purpose:** Emit only values that pass a condition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { filter } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Only even numbers
|
||||||
|
of(1, 2, 3, 4, 5).pipe(
|
||||||
|
filter(x => x % 2 === 0)
|
||||||
|
).subscribe(console.log);
|
||||||
|
// Output: 2, 4
|
||||||
|
|
||||||
|
// Only non-null users
|
||||||
|
this.user$.pipe(
|
||||||
|
filter(user => user !== null)
|
||||||
|
).subscribe(user => console.log(user.name));
|
||||||
|
|
||||||
|
// Only valid emails
|
||||||
|
this.emailInput$.pipe(
|
||||||
|
filter(email => this.isValidEmail(email))
|
||||||
|
).subscribe(email => this.checkAvailability(email));
|
||||||
|
```
|
||||||
|
|
||||||
|
### debounceTime
|
||||||
|
|
||||||
|
**Purpose:** Wait for silence before emitting
|
||||||
|
|
||||||
|
**Use when:** Search input, window resize
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { debounceTime } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Wait 300ms after user stops typing
|
||||||
|
this.searchInput$.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
switchMap(term => this.search(term))
|
||||||
|
).subscribe(results => this.results.set(results));
|
||||||
|
|
||||||
|
// Window resize handler
|
||||||
|
fromEvent(window, 'resize').pipe(
|
||||||
|
debounceTime(200)
|
||||||
|
).subscribe(() => this.handleResize());
|
||||||
|
```
|
||||||
|
|
||||||
|
### throttleTime
|
||||||
|
|
||||||
|
**Purpose:** Emit first value, then ignore for duration
|
||||||
|
|
||||||
|
**Use when:** Scroll events, rapid clicks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { throttleTime } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Handle scroll at most once per 100ms
|
||||||
|
fromEvent(window, 'scroll').pipe(
|
||||||
|
throttleTime(100)
|
||||||
|
).subscribe(() => this.checkScrollPosition());
|
||||||
|
|
||||||
|
// Rate-limit button clicks
|
||||||
|
this.buttonClick$.pipe(
|
||||||
|
throttleTime(1000)
|
||||||
|
).subscribe(() => this.handleClick());
|
||||||
|
```
|
||||||
|
|
||||||
|
### distinctUntilChanged
|
||||||
|
|
||||||
|
**Purpose:** Only emit when value changes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { distinctUntilChanged } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Only emit when search term actually changes
|
||||||
|
this.searchInput$.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap(term => this.search(term))
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
// Only emit when user ID changes
|
||||||
|
this.userId$.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap(id => this.loadUser(id))
|
||||||
|
).subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
### take / takeUntil
|
||||||
|
|
||||||
|
**Purpose:** Take specific number or until condition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { take, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Take first 5 values
|
||||||
|
this.stream$.pipe(
|
||||||
|
take(5)
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
// Take until component destroyed
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
this.data$.pipe(
|
||||||
|
takeUntil(this.destroy$)
|
||||||
|
).subscribe(data => console.log(data));
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modern approach with takeUntilDestroyed
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
|
this.data$.pipe(
|
||||||
|
takeUntilDestroyed()
|
||||||
|
).subscribe(data => console.log(data));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Category 3: Combination Operators
|
||||||
|
|
||||||
|
### combineLatest
|
||||||
|
|
||||||
|
**Purpose:** Emit when ANY source emits (after all emit at least once)
|
||||||
|
|
||||||
|
**Use when:** Combining multiple form fields, filters
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { combineLatest } from 'rxjs';
|
||||||
|
|
||||||
|
// Wait for both user and settings to load
|
||||||
|
combineLatest([
|
||||||
|
this.user$,
|
||||||
|
this.settings$
|
||||||
|
]).pipe(
|
||||||
|
map(([user, settings]) => ({ user, settings }))
|
||||||
|
).subscribe(data => console.log(data));
|
||||||
|
|
||||||
|
// Combine multiple filters
|
||||||
|
combineLatest([
|
||||||
|
this.searchTerm$,
|
||||||
|
this.category$,
|
||||||
|
this.priceRange$
|
||||||
|
]).pipe(
|
||||||
|
map(([search, category, price]) => ({
|
||||||
|
search, category, price
|
||||||
|
})),
|
||||||
|
switchMap(filters => this.fetchProducts(filters))
|
||||||
|
).subscribe(products => this.products.set(products));
|
||||||
|
```
|
||||||
|
|
||||||
|
### forkJoin
|
||||||
|
|
||||||
|
**Purpose:** Emit when ALL sources complete (like Promise.all)
|
||||||
|
|
||||||
|
**Use when:** Loading multiple independent resources
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
|
|
||||||
|
// Load multiple resources on init
|
||||||
|
forkJoin({
|
||||||
|
user: this.http.get('/api/user'),
|
||||||
|
config: this.http.get('/api/config'),
|
||||||
|
permissions: this.http.get('/api/permissions')
|
||||||
|
}).subscribe(({ user, config, permissions }) => {
|
||||||
|
this.initialize(user, config, permissions);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parallel API calls
|
||||||
|
forkJoin([
|
||||||
|
this.http.get('/api/products'),
|
||||||
|
this.http.get('/api/categories'),
|
||||||
|
this.http.get('/api/brands')
|
||||||
|
]).subscribe(([products, categories, brands]) => {
|
||||||
|
// All loaded
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### merge
|
||||||
|
|
||||||
|
**Purpose:** Emit from any source as soon as it emits
|
||||||
|
|
||||||
|
**Use when:** Combining event streams
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { merge } from 'rxjs';
|
||||||
|
|
||||||
|
// Combine multiple event sources
|
||||||
|
merge(
|
||||||
|
this.clicks$,
|
||||||
|
this.hovers$,
|
||||||
|
this.focuses$
|
||||||
|
).subscribe(event => this.trackEvent(event));
|
||||||
|
|
||||||
|
// Combine refresh triggers
|
||||||
|
merge(
|
||||||
|
this.manualRefresh$,
|
||||||
|
this.autoRefresh$,
|
||||||
|
this.dataChanged$
|
||||||
|
).pipe(
|
||||||
|
switchMap(() => this.loadData())
|
||||||
|
).subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
### withLatestFrom
|
||||||
|
|
||||||
|
**Purpose:** Combine with latest value from other observables
|
||||||
|
|
||||||
|
**Use when:** Need secondary data with primary stream
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withLatestFrom } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Submit form with latest user data
|
||||||
|
this.submitButton$.pipe(
|
||||||
|
withLatestFrom(this.form$, this.user$),
|
||||||
|
map(([_, formData, user]) => ({ formData, user }))
|
||||||
|
).subscribe(({ formData, user }) => {
|
||||||
|
this.submit(formData, user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply filter with latest settings
|
||||||
|
this.searchTerm$.pipe(
|
||||||
|
withLatestFrom(this.filters$, this.sortOrder$),
|
||||||
|
switchMap(([term, filters, sort]) =>
|
||||||
|
this.search(term, filters, sort)
|
||||||
|
)
|
||||||
|
).subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Category 4: Error Handling
|
||||||
|
|
||||||
|
### catchError
|
||||||
|
|
||||||
|
**Purpose:** Catch errors and return fallback observable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of, EMPTY } from 'rxjs';
|
||||||
|
|
||||||
|
// Return empty array on error
|
||||||
|
this.http.get('/api/data').pipe(
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Failed to load:', error);
|
||||||
|
return of([]); // Fallback value
|
||||||
|
})
|
||||||
|
).subscribe(data => console.log(data));
|
||||||
|
|
||||||
|
// Return empty observable (complete immediately)
|
||||||
|
this.http.get('/api/data').pipe(
|
||||||
|
catchError(() => EMPTY)
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
// Re-throw after logging
|
||||||
|
this.http.get('/api/data').pipe(
|
||||||
|
catchError(error => {
|
||||||
|
console.error(error);
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
).subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
### retry
|
||||||
|
|
||||||
|
**Purpose:** Retry failed observable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { retry } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Retry 3 times on failure
|
||||||
|
this.http.get('/api/data').pipe(
|
||||||
|
retry(3),
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Failed after 3 retries');
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
// Retry with delay (RxJS 7+)
|
||||||
|
import { retry } from 'rxjs/operators';
|
||||||
|
|
||||||
|
this.http.get('/api/data').pipe(
|
||||||
|
retry({
|
||||||
|
count: 3,
|
||||||
|
delay: 1000 // Wait 1s between retries
|
||||||
|
})
|
||||||
|
).subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Category 5: Utility Operators
|
||||||
|
|
||||||
|
### tap
|
||||||
|
|
||||||
|
**Purpose:** Perform side effects without modifying stream
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Log for debugging
|
||||||
|
this.http.get('/api/data').pipe(
|
||||||
|
tap(data => console.log('Received:', data)),
|
||||||
|
map(data => data.items),
|
||||||
|
tap(items => console.log('Mapped:', items))
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
// Track analytics
|
||||||
|
this.searchTerm$.pipe(
|
||||||
|
tap(term => this.analytics.track('search', { term })),
|
||||||
|
switchMap(term => this.search(term))
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
// Update loading state
|
||||||
|
this.loadData().pipe(
|
||||||
|
tap(() => this.loading.set(true)),
|
||||||
|
finalize(() => this.loading.set(false))
|
||||||
|
).subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
### shareReplay
|
||||||
|
|
||||||
|
**Purpose:** Share observable and replay values to new subscribers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { shareReplay } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Cache HTTP request
|
||||||
|
private config$ = this.http.get('/api/config').pipe(
|
||||||
|
shareReplay(1) // Cache last value
|
||||||
|
);
|
||||||
|
|
||||||
|
// Multiple subscribers get same value
|
||||||
|
this.config$.subscribe(config => console.log('Sub 1:', config));
|
||||||
|
this.config$.subscribe(config => console.log('Sub 2:', config));
|
||||||
|
// Only one HTTP request made!
|
||||||
|
```
|
||||||
|
|
||||||
|
### finalize
|
||||||
|
|
||||||
|
**Purpose:** Execute code when observable completes or errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
|
// Always hide loading spinner
|
||||||
|
this.loadData().pipe(
|
||||||
|
tap(() => this.loading.set(true)),
|
||||||
|
finalize(() => this.loading.set(false))
|
||||||
|
).subscribe({
|
||||||
|
next: data => console.log(data),
|
||||||
|
error: err => console.error(err)
|
||||||
|
// loading.set(false) runs regardless
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Search with Debounce
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.searchControl.valueChanges.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap(term => this.http.get(`/api/search?q=${term}`)),
|
||||||
|
catchError(() => of([]))
|
||||||
|
).subscribe(results => this.results.set(results));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Auto-Save
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.form.valueChanges.pipe(
|
||||||
|
debounceTime(2000),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
tap(() => this.saving.set(true)),
|
||||||
|
switchMap(value => this.http.put('/api/save', value)),
|
||||||
|
finalize(() => this.saving.set(false))
|
||||||
|
).subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Polling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { interval, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
interval(5000).pipe(
|
||||||
|
switchMap(() => this.http.get('/api/status')),
|
||||||
|
catchError(() => of(null))
|
||||||
|
).subscribe(status => this.status.set(status));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Type-ahead with Minimum Length
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.searchInput$.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
filter(term => term.length >= 3),
|
||||||
|
switchMap(term => this.search(term)),
|
||||||
|
catchError(() => of([]))
|
||||||
|
).subscribe(results => this.results.set(results));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Tree
|
||||||
|
|
||||||
|
**Need to transform values?** → `map`
|
||||||
|
**Need to switch to new observable?** → `switchMap`
|
||||||
|
**Need to wait for all to complete?** → `forkJoin`
|
||||||
|
**Need to combine latest values?** → `combineLatest`
|
||||||
|
**Need to filter values?** → `filter`
|
||||||
|
**Need to handle errors?** → `catchError`
|
||||||
|
**Need to retry?** → `retry`
|
||||||
|
**Need to share result?** → `shareReplay`
|
||||||
|
**Need to debounce?** → `debounceTime`
|
||||||
|
**Need to throttle?** → `throttleTime`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always unsubscribe** - Use `takeUntilDestroyed()` or `async` pipe
|
||||||
|
2. **Prefer `async` pipe** over manual subscriptions
|
||||||
|
3. **Use `switchMap`** for dependent HTTP requests
|
||||||
|
4. **Use `forkJoin`** for parallel independent requests
|
||||||
|
5. **Add error handling** with `catchError`
|
||||||
|
6. **Cache with `shareReplay`** for expensive operations
|
||||||
|
7. **Debounce user input** with `debounceTime`
|
||||||
|
8. **Log with `tap`** for debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
**❌ Memory leaks:**
|
||||||
|
```typescript
|
||||||
|
// Bad - no unsubscribe
|
||||||
|
this.data$.subscribe(data => console.log(data));
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Fixed:**
|
||||||
|
```typescript
|
||||||
|
// Good - auto unsubscribe
|
||||||
|
this.data$.pipe(
|
||||||
|
takeUntilDestroyed()
|
||||||
|
).subscribe(data => console.log(data));
|
||||||
|
|
||||||
|
// Or use async pipe
|
||||||
|
template: `{{ data$ | async }}`
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ Nested subscriptions:**
|
||||||
|
```typescript
|
||||||
|
// Bad - pyramid of doom
|
||||||
|
this.users$.subscribe(users => {
|
||||||
|
this.http.get('/api/settings').subscribe(settings => {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Fixed:**
|
||||||
|
```typescript
|
||||||
|
// Good - use switchMap
|
||||||
|
this.users$.pipe(
|
||||||
|
switchMap(users => this.http.get('/api/settings').pipe(
|
||||||
|
map(settings => ({ users, settings }))
|
||||||
|
))
|
||||||
|
).subscribe(({ users, settings }) => {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Master these operators:
|
||||||
|
- **Transformation**: `map`, `switchMap`, `mergeMap`
|
||||||
|
- **Filtering**: `filter`, `debounceTime`, `distinctUntilChanged`
|
||||||
|
- **Combination**: `combineLatest`, `forkJoin`, `withLatestFrom`
|
||||||
|
- **Error Handling**: `catchError`, `retry`
|
||||||
|
- **Utility**: `tap`, `shareReplay`, `take`, `takeUntil`
|
||||||
|
|
||||||
|
**Key Takeaway:** Choose the right operator for the job. `switchMap` for search, `forkJoin` for parallel loads, `combineLatest` for combining streams.
|
||||||
626
skills/signals-patterns/SKILL.md
Normal file
626
skills/signals-patterns/SKILL.md
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
# Angular Signals Patterns
|
||||||
|
|
||||||
|
**Version:** Angular 16+
|
||||||
|
**Status:** Stable (Developer Preview in 16, Stable in 17+)
|
||||||
|
**Purpose:** Modern reactive state management without Zone.js overhead
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concept
|
||||||
|
|
||||||
|
**Signals** are Angular's modern approach to reactive state management. Unlike observables, signals are synchronous, glitch-free, and optimized for change detection.
|
||||||
|
|
||||||
|
**Key Benefits:**
|
||||||
|
- ✅ Simpler mental model than RxJS
|
||||||
|
- ✅ Better performance (no Zone.js overhead)
|
||||||
|
- ✅ Fine-grained reactivity
|
||||||
|
- ✅ Type-safe
|
||||||
|
- ✅ Works seamlessly with OnPush change detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signal Basics
|
||||||
|
|
||||||
|
### Creating Signals
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { signal, computed, effect } from '@angular/core';
|
||||||
|
|
||||||
|
// Writable signal
|
||||||
|
const count = signal(0); // number signal
|
||||||
|
const name = signal('John'); // string signal
|
||||||
|
const user = signal<User | null>(null); // object signal with null
|
||||||
|
|
||||||
|
// Read value (call as function)
|
||||||
|
console.log(count()); // 0
|
||||||
|
console.log(name()); // "John"
|
||||||
|
|
||||||
|
// Write value
|
||||||
|
count.set(5); // Set to exact value
|
||||||
|
name.set('Jane');
|
||||||
|
|
||||||
|
// Update value (based on current)
|
||||||
|
count.update(n => n + 1); // Increment
|
||||||
|
```
|
||||||
|
|
||||||
|
### Computed Signals
|
||||||
|
|
||||||
|
Computed signals automatically recalculate when dependencies change:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const count = signal(0);
|
||||||
|
|
||||||
|
// Derived value
|
||||||
|
const double = computed(() => count() * 2);
|
||||||
|
const isEven = computed(() => count() % 2 === 0);
|
||||||
|
const message = computed(() =>
|
||||||
|
`Count is ${count()} and ${isEven() ? 'even' : 'odd'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(double()); // 0
|
||||||
|
console.log(message()); // "Count is 0 and even"
|
||||||
|
|
||||||
|
count.set(3);
|
||||||
|
|
||||||
|
console.log(double()); // 6
|
||||||
|
console.log(message()); // "Count is 3 and odd"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Effects
|
||||||
|
|
||||||
|
Effects run side effects when signals change:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { effect } from '@angular/core';
|
||||||
|
|
||||||
|
const count = signal(0);
|
||||||
|
|
||||||
|
// Effect runs when count changes
|
||||||
|
effect(() => {
|
||||||
|
console.log(`Count changed to: ${count()}`);
|
||||||
|
localStorage.setItem('count', count().toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
count.set(5); // Logs: "Count changed to: 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern 1: Component State
|
||||||
|
|
||||||
|
### Basic Component State
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, signal, computed } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-todo-list',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<input
|
||||||
|
[value]="newTodo()"
|
||||||
|
(input)="newTodo.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
<button (click)="addTodo()">Add</button>
|
||||||
|
|
||||||
|
<p>Total: {{ total() }} | Active: {{ active() }} | Completed: {{ completed() }}</p>
|
||||||
|
|
||||||
|
@for (todo of todos(); track todo.id) {
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="todo.completed"
|
||||||
|
(change)="toggle(todo.id)"
|
||||||
|
/>
|
||||||
|
<span [class.line-through]="todo.completed">
|
||||||
|
{{ todo.text }}
|
||||||
|
</span>
|
||||||
|
<button (click)="remove(todo.id)">×</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class TodoListComponent {
|
||||||
|
// State
|
||||||
|
todos = signal<Todo[]>([]);
|
||||||
|
newTodo = signal('');
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
total = computed(() => this.todos().length);
|
||||||
|
active = computed(() => this.todos().filter(t => !t.completed).length);
|
||||||
|
completed = computed(() => this.todos().filter(t => t.completed).length);
|
||||||
|
|
||||||
|
addTodo() {
|
||||||
|
if (this.newTodo().trim()) {
|
||||||
|
this.todos.update(todos => [
|
||||||
|
...todos,
|
||||||
|
{
|
||||||
|
id: Date.now().toString(),
|
||||||
|
text: this.newTodo(),
|
||||||
|
completed: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
this.newTodo.set('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(id: string) {
|
||||||
|
this.todos.update(todos =>
|
||||||
|
todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string) {
|
||||||
|
this.todos.update(todos => todos.filter(t => t.id !== id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern 2: Derived State
|
||||||
|
|
||||||
|
### Computed from Multiple Signals
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({...})
|
||||||
|
export class ShoppingCartComponent {
|
||||||
|
items = signal<CartItem[]>([]);
|
||||||
|
taxRate = signal(0.08);
|
||||||
|
shippingCost = signal(5.99);
|
||||||
|
|
||||||
|
// Computed from items
|
||||||
|
subtotal = computed(() =>
|
||||||
|
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Computed from subtotal and taxRate
|
||||||
|
tax = computed(() => this.subtotal() * this.taxRate());
|
||||||
|
|
||||||
|
// Computed from multiple signals
|
||||||
|
total = computed(() =>
|
||||||
|
this.subtotal() + this.tax() + this.shippingCost()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Computed boolean
|
||||||
|
hasItems = computed(() => this.items().length > 0);
|
||||||
|
canCheckout = computed(() =>
|
||||||
|
this.hasItems() && this.total() > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern 3: Signal Arrays
|
||||||
|
|
||||||
|
### Immutable Array Updates
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({...})
|
||||||
|
export class ListComponent {
|
||||||
|
items = signal<Item[]>([]);
|
||||||
|
|
||||||
|
// Add item
|
||||||
|
addItem(item: Item) {
|
||||||
|
this.items.update(current => [...current, item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove item
|
||||||
|
removeItem(id: string) {
|
||||||
|
this.items.update(current => current.filter(item => item.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update item
|
||||||
|
updateItem(id: string, updates: Partial<Item>) {
|
||||||
|
this.items.update(current =>
|
||||||
|
current.map(item =>
|
||||||
|
item.id === id ? { ...item, ...updates } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort items
|
||||||
|
sortBy(key: keyof Item) {
|
||||||
|
this.items.update(current =>
|
||||||
|
[...current].sort((a, b) => a[key] > b[key] ? 1 : -1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter items
|
||||||
|
filteredItems = computed(() =>
|
||||||
|
this.items().filter(item => item.active)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern 4: Signal Objects
|
||||||
|
|
||||||
|
### Nested Object Updates
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
preferences: {
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
language: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({...})
|
||||||
|
export class UserProfileComponent {
|
||||||
|
user = signal<User>({
|
||||||
|
id: '1',
|
||||||
|
name: 'John',
|
||||||
|
email: 'john@example.com',
|
||||||
|
preferences: {
|
||||||
|
theme: 'light',
|
||||||
|
language: 'en'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update top-level property
|
||||||
|
updateName(name: string) {
|
||||||
|
this.user.update(u => ({ ...u, name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update nested property
|
||||||
|
updateTheme(theme: 'light' | 'dark') {
|
||||||
|
this.user.update(u => ({
|
||||||
|
...u,
|
||||||
|
preferences: {
|
||||||
|
...u.preferences,
|
||||||
|
theme
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed from nested property
|
||||||
|
isDarkMode = computed(() => this.user().preferences.theme === 'dark');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern 5: Loading States
|
||||||
|
|
||||||
|
### Common Loading Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface LoadingState<T> {
|
||||||
|
loading: boolean;
|
||||||
|
data: T | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({...})
|
||||||
|
export class DataComponent {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
state = signal<LoadingState<Product[]>>({
|
||||||
|
loading: false,
|
||||||
|
data: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
isLoading = computed(() => this.state().loading);
|
||||||
|
hasError = computed(() => this.state().error !== null);
|
||||||
|
hasData = computed(() => this.state().data !== null);
|
||||||
|
products = computed(() => this.state().data ?? []);
|
||||||
|
|
||||||
|
loadData() {
|
||||||
|
this.state.update(s => ({ ...s, loading: true, error: null }));
|
||||||
|
|
||||||
|
this.http.get<Product[]>('/api/products').subscribe({
|
||||||
|
next: data => this.state.set({ loading: false, data, error: null }),
|
||||||
|
error: err => this.state.set({ loading: false, data: null, error: err.message })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern 6: Form State
|
||||||
|
|
||||||
|
### Signal-Based Form
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({
|
||||||
|
selector: 'app-signup-form',
|
||||||
|
template: `
|
||||||
|
<form (submit)="handleSubmit($event)">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
[value]="email()"
|
||||||
|
(input)="email.set($any($event.target).value)"
|
||||||
|
[class.invalid]="emailError()"
|
||||||
|
/>
|
||||||
|
@if (emailError()) {
|
||||||
|
<span class="error">{{ emailError() }}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
[value]="password()"
|
||||||
|
(input)="password.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button [disabled]="!isValid()">Sign Up</button>
|
||||||
|
</form>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class SignupFormComponent {
|
||||||
|
// Form fields
|
||||||
|
email = signal('');
|
||||||
|
password = signal('');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
emailError = computed(() => {
|
||||||
|
const value = this.email();
|
||||||
|
if (!value) return 'Email is required';
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||||
|
return 'Invalid email format';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
passwordError = computed(() => {
|
||||||
|
const value = this.password();
|
||||||
|
if (!value) return 'Password is required';
|
||||||
|
if (value.length < 8) return 'Minimum 8 characters';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
isValid = computed(() =>
|
||||||
|
!this.emailError() && !this.passwordError()
|
||||||
|
);
|
||||||
|
|
||||||
|
handleSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.isValid()) {
|
||||||
|
console.log({ email: this.email(), password: this.password() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern 7: Signal Effects
|
||||||
|
|
||||||
|
### Side Effects with Cleanup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({...})
|
||||||
|
export class AutoSaveComponent {
|
||||||
|
content = signal('');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Auto-save effect
|
||||||
|
effect(() => {
|
||||||
|
const data = this.content();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
console.log('Auto-saving:', data);
|
||||||
|
this.save(data);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log changes
|
||||||
|
effect(() => {
|
||||||
|
console.log('Content length:', this.content().length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
save(data: string) {
|
||||||
|
localStorage.setItem('draft', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern 8: Signals with RxJS
|
||||||
|
|
||||||
|
### Converting Between Signals and Observables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { interval } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({...})
|
||||||
|
export class MixedComponent {
|
||||||
|
// Observable to Signal
|
||||||
|
private tick$ = interval(1000);
|
||||||
|
tick = toSignal(this.tick$, { initialValue: 0 });
|
||||||
|
|
||||||
|
// Signal to Observable
|
||||||
|
count = signal(0);
|
||||||
|
count$ = toObservable(this.count);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Subscribe to signal as observable
|
||||||
|
this.count$.subscribe(value => {
|
||||||
|
console.log('Count changed:', value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combining Signals with HTTP
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Component({...})
|
||||||
|
export class UserComponent {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
userId = signal('123');
|
||||||
|
|
||||||
|
// Convert signal to observable, then fetch
|
||||||
|
user = toSignal(
|
||||||
|
toObservable(this.userId).pipe(
|
||||||
|
switchMap(id => this.http.get<User>(`/api/users/${id}`))
|
||||||
|
),
|
||||||
|
{ initialValue: null }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern 9: Global State with Signals
|
||||||
|
|
||||||
|
### Signal-Based Store
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// store.service.ts
|
||||||
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
user: User | null;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
notifications: Notification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class Store {
|
||||||
|
// Private state
|
||||||
|
private state = signal<AppState>({
|
||||||
|
user: null,
|
||||||
|
theme: 'light',
|
||||||
|
notifications: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public selectors
|
||||||
|
user = computed(() => this.state().user);
|
||||||
|
theme = computed(() => this.state().theme);
|
||||||
|
notifications = computed(() => this.state().notifications);
|
||||||
|
unreadCount = computed(() =>
|
||||||
|
this.state().notifications.filter(n => !n.read).length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setUser(user: User | null) {
|
||||||
|
this.state.update(s => ({ ...s, user }));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
this.state.update(s => ({
|
||||||
|
...s,
|
||||||
|
theme: s.theme === 'light' ? 'dark' : 'light'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
addNotification(notification: Notification) {
|
||||||
|
this.state.update(s => ({
|
||||||
|
...s,
|
||||||
|
notifications: [...s.notifications, notification]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in component
|
||||||
|
@Component({...})
|
||||||
|
export class AppComponent {
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
user = this.store.user;
|
||||||
|
theme = this.store.theme;
|
||||||
|
unreadCount = this.store.unreadCount;
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.store.setUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Do's ✅
|
||||||
|
|
||||||
|
1. **Use signals for synchronous state**
|
||||||
|
2. **Prefer computed over manual updates**
|
||||||
|
3. **Keep signals immutable** - always create new objects/arrays
|
||||||
|
4. **Use effects for side effects only**
|
||||||
|
5. **Combine with OnPush** change detection
|
||||||
|
6. **Use toSignal for observables** in components
|
||||||
|
|
||||||
|
### Don'ts ❌
|
||||||
|
|
||||||
|
1. **Don't mutate signal values directly**
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad
|
||||||
|
items().push(newItem);
|
||||||
|
|
||||||
|
// ✅ Good
|
||||||
|
items.update(current => [...current, newItem]);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Don't use effects for derived state**
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - Use computed instead
|
||||||
|
const count = signal(0);
|
||||||
|
const double = signal(0);
|
||||||
|
effect(() => double.set(count() * 2));
|
||||||
|
|
||||||
|
// ✅ Good
|
||||||
|
const double = computed(() => count() * 2);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Don't create signals in loops**
|
||||||
|
4. **Don't read signals in constructors** (use ngOnInit or effects)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. **Computed signals are cached** - only recalculate when dependencies change
|
||||||
|
2. **Signals trigger change detection only when value changes**
|
||||||
|
3. **Use OnPush** change detection with signals for best performance
|
||||||
|
4. **Signals are more efficient than observables** for synchronous state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to Use Signals vs RxJS
|
||||||
|
|
||||||
|
**Use Signals for:**
|
||||||
|
- Local component state
|
||||||
|
- Derived/computed values
|
||||||
|
- Form state
|
||||||
|
- UI state (loading, errors)
|
||||||
|
|
||||||
|
**Use RxJS for:**
|
||||||
|
- Asynchronous operations
|
||||||
|
- HTTP requests
|
||||||
|
- WebSocket streams
|
||||||
|
- Time-based operations (debounce, throttle)
|
||||||
|
- Complex async flows
|
||||||
|
|
||||||
|
**Use Both:**
|
||||||
|
- Convert observables to signals with `toSignal()`
|
||||||
|
- Convert signals to observables with `toObservable()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Signals provide:
|
||||||
|
- ✅ Simpler reactive state management
|
||||||
|
- ✅ Better performance
|
||||||
|
- ✅ Type safety
|
||||||
|
- ✅ Fine-grained reactivity
|
||||||
|
- ✅ Seamless integration with Angular
|
||||||
|
|
||||||
|
**Key Takeaway:** Signals are the future of Angular state management. Use them for synchronous state, computed values, and reactive UI updates.
|
||||||
Reference in New Issue
Block a user