Initial commit
This commit is contained in:
336
skills/core/SKILL.md
Normal file
336
skills/core/SKILL.md
Normal file
@@ -0,0 +1,336 @@
|
||||
---
|
||||
name: angular-core-implementation
|
||||
description: Generate Angular components, services, modules, and directives. Implement dependency injection, lifecycle hooks, data binding, and build production-ready Angular architectures.
|
||||
---
|
||||
|
||||
# Angular Core Implementation Skill
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Component Basics
|
||||
```typescript
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-card',
|
||||
template: `
|
||||
<div class="card">
|
||||
<h2>{{ user.name }}</h2>
|
||||
<p>{{ user.email }}</p>
|
||||
<button (click)="onDelete()">Delete</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.card { border: 1px solid #ddd; padding: 16px; }
|
||||
`]
|
||||
})
|
||||
export class UserCardComponent {
|
||||
@Input() user!: User;
|
||||
@Output() deleted = new EventEmitter<void>();
|
||||
|
||||
onDelete() {
|
||||
this.deleted.emit();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service Creation
|
||||
```typescript
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root' // Singleton service
|
||||
})
|
||||
export class UserService {
|
||||
private apiUrl = '/api/users';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getUsers(): Observable<User[]> {
|
||||
return this.http.get<User[]>(this.apiUrl);
|
||||
}
|
||||
|
||||
getUser(id: number): Observable<User> {
|
||||
return this.http.get<User>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
createUser(user: User): Observable<User> {
|
||||
return this.http.post<User>(this.apiUrl, user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
constructor(
|
||||
private logger: LoggerService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
notify(message: string) {
|
||||
this.logger.log(message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Lifecycle Hooks
|
||||
```typescript
|
||||
export class UserListComponent implements
|
||||
OnInit,
|
||||
OnChanges,
|
||||
OnDestroy
|
||||
{
|
||||
@Input() users: User[] = [];
|
||||
|
||||
ngOnInit() {
|
||||
// Initialize component, fetch data
|
||||
this.loadUsers();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
// Respond to input changes
|
||||
if (changes['users']) {
|
||||
this.onUsersChanged();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
// Cleanup subscriptions, remove listeners
|
||||
this.subscription?.unsubscribe();
|
||||
}
|
||||
|
||||
private loadUsers() { /* ... */ }
|
||||
private onUsersChanged() { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**Lifecycle Order:**
|
||||
1. `ngOnChanges` - When input properties change
|
||||
2. `ngOnInit` - After first ngOnChanges
|
||||
3. `ngDoCheck` - Every change detection cycle
|
||||
4. `ngAfterContentInit` - After content is initialized
|
||||
5. `ngAfterContentChecked` - After content is checked
|
||||
6. `ngAfterViewInit` - After view is initialized
|
||||
7. `ngAfterViewChecked` - After view is checked
|
||||
8. `ngOnDestroy` - When component is destroyed
|
||||
|
||||
### Modules
|
||||
```typescript
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
UserListComponent,
|
||||
UserDetailComponent,
|
||||
UserFormComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
],
|
||||
exports: [
|
||||
UserListComponent,
|
||||
UserDetailComponent
|
||||
]
|
||||
})
|
||||
export class UserModule { }
|
||||
```
|
||||
|
||||
### Lazy Loading
|
||||
```typescript
|
||||
const routes: Routes = [
|
||||
{ path: 'users', loadChildren: () =>
|
||||
import('./users/users.module').then(m => m.UsersModule)
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Content Projection
|
||||
```typescript
|
||||
// Parent component
|
||||
<app-card>
|
||||
<div class="header">Card Title</div>
|
||||
<div class="content">Card content</div>
|
||||
</app-card>
|
||||
|
||||
// Card component
|
||||
@Component({
|
||||
selector: 'app-card',
|
||||
template: `
|
||||
<div class="card">
|
||||
<ng-content select=".header"></ng-content>
|
||||
<ng-content select=".content"></ng-content>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class CardComponent { }
|
||||
```
|
||||
|
||||
### ViewChild and ContentChild
|
||||
```typescript
|
||||
@Component({
|
||||
selector: 'app-form',
|
||||
template: `<app-input #firstInput></app-input>`
|
||||
})
|
||||
export class FormComponent implements AfterViewInit {
|
||||
@ViewChild('firstInput') firstInput!: InputComponent;
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.firstInput.focus();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Directive
|
||||
```typescript
|
||||
@Directive({
|
||||
selector: '[appHighlight]'
|
||||
})
|
||||
export class HighlightDirective {
|
||||
constructor(private el: ElementRef) {
|
||||
this.el.nativeElement.style.backgroundColor = 'yellow';
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: <p appHighlight>Highlighted text</p>
|
||||
```
|
||||
|
||||
## Encapsulation
|
||||
|
||||
### View Encapsulation Modes
|
||||
```typescript
|
||||
@Component({
|
||||
selector: 'app-card',
|
||||
template: `<div class="card">...</div>`,
|
||||
styles: [`.card { color: blue; }`],
|
||||
encapsulation: ViewEncapsulation.Emulated // Default
|
||||
})
|
||||
export class CardComponent { }
|
||||
```
|
||||
|
||||
- **Emulated** (default): CSS scoped to component
|
||||
- **None**: Global styles
|
||||
- **ShadowDom**: Uses browser shadow DOM
|
||||
|
||||
## Change Detection
|
||||
|
||||
### OnPush Strategy
|
||||
```typescript
|
||||
@Component({
|
||||
selector: 'app-user',
|
||||
template: `<div>{{ user.name }}</div>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class UserComponent {
|
||||
@Input() user!: User;
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef) {}
|
||||
|
||||
manualDetection() {
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Patterns
|
||||
|
||||
### Multi-Provider
|
||||
```typescript
|
||||
@NgModule({
|
||||
providers: [
|
||||
{ provide: VALIDATORS, useValue: emailValidator, multi: true },
|
||||
{ provide: VALIDATORS, useValue: minLengthValidator, multi: true }
|
||||
]
|
||||
})
|
||||
export class ValidatorsModule { }
|
||||
```
|
||||
|
||||
### Factory Pattern
|
||||
```typescript
|
||||
@NgModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ConfigService,
|
||||
useFactory: (env: EnvironmentService) => {
|
||||
return env.production ?
|
||||
new ProdConfigService() :
|
||||
new DevConfigService();
|
||||
},
|
||||
deps: [EnvironmentService]
|
||||
}
|
||||
]
|
||||
})
|
||||
export class AppModule { }
|
||||
```
|
||||
|
||||
## Testing Components
|
||||
```typescript
|
||||
describe('UserCardComponent', () => {
|
||||
let component: UserCardComponent;
|
||||
let fixture: ComponentFixture<UserCardComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [UserCardComponent]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UserCardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should emit deleted when delete button clicked', () => {
|
||||
spyOn(component.deleted, 'emit');
|
||||
component.user = { id: 1, name: 'John', email: 'john@example.com' };
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.debugElement.query(By.css('button')).nativeElement.click();
|
||||
expect(component.deleted.emit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
1. **Use OnPush**: Reduces change detection cycles
|
||||
2. **Unsubscribe**: Prevent memory leaks
|
||||
3. **TrackBy**: Optimize *ngFor rendering
|
||||
4. **Lazy Load**: Load modules on demand
|
||||
5. **Avoid property binding in templates**: Use async pipe
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
users: User[] = [];
|
||||
|
||||
// Good
|
||||
users$ = this.userService.getUsers();
|
||||
|
||||
<!-- Template -->
|
||||
<app-user *ngFor="let user of users$ | async; trackBy: trackByUserId">
|
||||
</app-user>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Smart vs Presentational**: Container components handle logic
|
||||
2. **One Responsibility**: Each component has a single purpose
|
||||
3. **Input/Output**: Use @Input/@Output for communication
|
||||
4. **Services**: Handle business logic and HTTP
|
||||
5. **DI**: Always use dependency injection
|
||||
6. **OnDestroy**: Clean up subscriptions
|
||||
|
||||
## Resources
|
||||
|
||||
- [Angular Documentation](https://angular.io/docs)
|
||||
- [Angular Best Practices](https://angular.io/guide/styleguide)
|
||||
- [Component Interaction](https://angular.io/guide/component-interaction)
|
||||
381
skills/forms/SKILL.md
Normal file
381
skills/forms/SKILL.md
Normal file
@@ -0,0 +1,381 @@
|
||||
---
|
||||
name: forms-implementation
|
||||
description: Build reactive and template-driven forms, implement custom validators, create async validators, add cross-field validation, and generate dynamic forms for Angular applications.
|
||||
---
|
||||
|
||||
# Forms Implementation Skill
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Template-Driven Forms
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { NgForm } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contact',
|
||||
template: `
|
||||
<form #contactForm="ngForm" (ngSubmit)="onSubmit(contactForm)">
|
||||
<input
|
||||
[(ngModel)]="model.name"
|
||||
name="name"
|
||||
required
|
||||
minlength="3"
|
||||
/>
|
||||
<input
|
||||
[(ngModel)]="model.email"
|
||||
name="email"
|
||||
email
|
||||
/>
|
||||
<button [disabled]="!contactForm.valid">Submit</button>
|
||||
</form>
|
||||
`
|
||||
})
|
||||
export class ContactComponent {
|
||||
model = { name: '', email: '' };
|
||||
|
||||
onSubmit(form: NgForm) {
|
||||
if (form.valid) {
|
||||
console.log('Form submitted:', form.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reactive Forms
|
||||
```typescript
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-form',
|
||||
template: `
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input formControlName="name" placeholder="Name" />
|
||||
<input formControlName="email" type="email" />
|
||||
<input formControlName="password" type="password" />
|
||||
<button [disabled]="form.invalid">Register</button>
|
||||
</form>
|
||||
`
|
||||
})
|
||||
export class UserFormComponent implements OnInit {
|
||||
form!: FormGroup;
|
||||
|
||||
constructor(private fb: FormBuilder) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.form = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.minLength(3)]],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(8)]]
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.form.valid) {
|
||||
console.log(this.form.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Form Controls
|
||||
|
||||
### FormControl
|
||||
```typescript
|
||||
// Create standalone control
|
||||
const nameControl = new FormControl('', Validators.required);
|
||||
|
||||
// Get value
|
||||
nameControl.value
|
||||
|
||||
// Set value
|
||||
nameControl.setValue('John');
|
||||
nameControl.patchValue({ name: 'John' });
|
||||
|
||||
// Check validity
|
||||
nameControl.valid
|
||||
nameControl.invalid
|
||||
nameControl.errors
|
||||
|
||||
// Listen to changes
|
||||
nameControl.valueChanges.subscribe(value => {
|
||||
console.log('Changed:', value);
|
||||
});
|
||||
```
|
||||
|
||||
### FormGroup
|
||||
```typescript
|
||||
const form = new FormGroup({
|
||||
name: new FormControl('', Validators.required),
|
||||
email: new FormControl('', [Validators.required, Validators.email]),
|
||||
address: new FormGroup({
|
||||
street: new FormControl(''),
|
||||
city: new FormControl(''),
|
||||
zip: new FormControl('')
|
||||
})
|
||||
});
|
||||
|
||||
// Access nested controls
|
||||
form.get('address.street')?.setValue('123 Main St');
|
||||
|
||||
// Update multiple values
|
||||
form.patchValue({
|
||||
name: 'John',
|
||||
email: 'john@example.com'
|
||||
});
|
||||
```
|
||||
|
||||
### FormArray
|
||||
```typescript
|
||||
const form = new FormGroup({
|
||||
name: new FormControl(''),
|
||||
emails: new FormArray([
|
||||
new FormControl(''),
|
||||
new FormControl('')
|
||||
])
|
||||
});
|
||||
|
||||
// Dynamic form array
|
||||
const emailsArray = form.get('emails') as FormArray;
|
||||
|
||||
// Add control
|
||||
emailsArray.push(new FormControl(''));
|
||||
|
||||
// Remove control
|
||||
emailsArray.removeAt(0);
|
||||
|
||||
// Iterate
|
||||
emailsArray.controls.forEach((control, index) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### Built-in Validators
|
||||
```typescript
|
||||
import { Validators } from '@angular/forms';
|
||||
|
||||
new FormControl('', [
|
||||
Validators.required,
|
||||
Validators.minLength(3),
|
||||
Validators.maxLength(50),
|
||||
Validators.pattern(/^[a-z]/i),
|
||||
Validators.email,
|
||||
Validators.min(0),
|
||||
Validators.max(100)
|
||||
])
|
||||
```
|
||||
|
||||
### Custom Validators
|
||||
```typescript
|
||||
// Simple validator
|
||||
function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
|
||||
if (control.value && control.value.includes(' ')) {
|
||||
return { hasSpaces: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cross-field validator
|
||||
function passwordMatchValidator(group: FormGroup): ValidationErrors | null {
|
||||
const password = group.get('password')?.value;
|
||||
const confirm = group.get('confirmPassword')?.value;
|
||||
|
||||
return password === confirm ? null : { passwordMismatch: true };
|
||||
}
|
||||
|
||||
// Usage
|
||||
const form = new FormGroup({
|
||||
username: new FormControl('', noSpacesValidator),
|
||||
password: new FormControl(''),
|
||||
confirmPassword: new FormControl('')
|
||||
}, passwordMatchValidator);
|
||||
```
|
||||
|
||||
### Async Validators
|
||||
```typescript
|
||||
function emailAvailableValidator(service: UserService): AsyncValidatorFn {
|
||||
return (control: AbstractControl): Observable<ValidationErrors | null> => {
|
||||
if (!control.value) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return service.checkEmailAvailable(control.value).pipe(
|
||||
map(available => available ? null : { emailTaken: true }),
|
||||
debounceTime(300),
|
||||
first()
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
new FormControl('', {
|
||||
validators: Validators.required,
|
||||
asyncValidators: emailAvailableValidator(userService),
|
||||
updateOn: 'blur'
|
||||
});
|
||||
```
|
||||
|
||||
## Form State
|
||||
```typescript
|
||||
const control = form.get('email')!;
|
||||
|
||||
// Pristine/Dirty
|
||||
control.pristine // Not modified by user
|
||||
control.dirty // Modified by user
|
||||
|
||||
// Touched/Untouched
|
||||
control.untouched // Never focused
|
||||
control.touched // Focused at least once
|
||||
|
||||
// Valid/Invalid
|
||||
control.valid
|
||||
control.invalid
|
||||
control.errors
|
||||
control.pending // Async validation in progress
|
||||
|
||||
// Status
|
||||
control.status // 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED'
|
||||
|
||||
// Value
|
||||
control.value
|
||||
control.getRawValue() // Include disabled controls
|
||||
```
|
||||
|
||||
## Form Display
|
||||
|
||||
### Showing Errors
|
||||
```typescript
|
||||
<div *ngIf="form.get('email')?.hasError('required')">
|
||||
Email is required
|
||||
</div>
|
||||
|
||||
<div *ngIf="form.get('email')?.hasError('email')">
|
||||
Invalid email format
|
||||
</div>
|
||||
|
||||
<div *ngIf="form.get('email')?.hasError('emailTaken')">
|
||||
Email already in use
|
||||
</div>
|
||||
```
|
||||
|
||||
### Dynamic Forms
|
||||
```typescript
|
||||
@Component({
|
||||
template: `
|
||||
<form [formGroup]="form">
|
||||
<div formArrayName="items">
|
||||
<div *ngFor="let item of items.controls; let i = index">
|
||||
<input [formControlName]="i" />
|
||||
<button (click)="removeItem(i)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<button (click)="addItem()">Add Item</button>
|
||||
</form>
|
||||
`
|
||||
})
|
||||
export class DynamicFormComponent {
|
||||
form!: FormGroup;
|
||||
|
||||
get items() {
|
||||
return this.form.get('items') as FormArray;
|
||||
}
|
||||
|
||||
addItem() {
|
||||
this.items.push(new FormControl('', Validators.required));
|
||||
}
|
||||
|
||||
removeItem(index: number) {
|
||||
this.items.removeAt(index);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### FormBuilder Groups
|
||||
```typescript
|
||||
this.form = this.fb.group({
|
||||
basicInfo: this.fb.group({
|
||||
firstName: ['', Validators.required],
|
||||
lastName: ['', Validators.required],
|
||||
email: ['', [Validators.required, Validators.email]]
|
||||
}),
|
||||
address: this.fb.group({
|
||||
street: [''],
|
||||
city: [''],
|
||||
zip: ['']
|
||||
}),
|
||||
preferences: this.fb.array([])
|
||||
});
|
||||
```
|
||||
|
||||
### Directives for Template Forms
|
||||
```typescript
|
||||
<form #form="ngForm">
|
||||
<input
|
||||
[(ngModel)]="user.name"
|
||||
name="name"
|
||||
required
|
||||
minlength="3"
|
||||
#nameField="ngModelGroup"
|
||||
/>
|
||||
|
||||
<div *ngIf="nameField.invalid && nameField.touched">
|
||||
<p *ngIf="nameField.errors?.['required']">Required</p>
|
||||
<p *ngIf="nameField.errors?.['minlength']">Min length 3</p>
|
||||
</div>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Testing Forms
|
||||
|
||||
```typescript
|
||||
describe('UserFormComponent', () => {
|
||||
let component: UserFormComponent;
|
||||
let fixture: ComponentFixture<UserFormComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [UserFormComponent],
|
||||
imports: [ReactiveFormsModule]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UserFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should submit valid form', () => {
|
||||
component.form.patchValue({
|
||||
name: 'John',
|
||||
email: 'john@example.com'
|
||||
});
|
||||
|
||||
expect(component.form.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should show error on invalid email', () => {
|
||||
component.form.get('email')?.setValue('invalid');
|
||||
expect(component.form.get('email')?.hasError('email')).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Reactive Forms for Complex**: Use for validation, computed fields
|
||||
2. **Template Forms for Simple**: Use for simple, data-binding heavy forms
|
||||
3. **Always validate**: Server and client validation
|
||||
4. **Disable submit until valid**: Better UX
|
||||
5. **Show errors appropriately**: After touched/dirty
|
||||
6. **Handle async validation**: Debounce, cancel on unsubscribe
|
||||
7. **Test forms thoroughly**: Validation, submission, edge cases
|
||||
|
||||
## Resources
|
||||
|
||||
- [Angular Forms Guide](https://angular.io/guide/forms)
|
||||
- [Reactive Forms](https://angular.io/guide/reactive-forms)
|
||||
- [Form Validation](https://angular.io/guide/form-validation)
|
||||
586
skills/modern-angular/SKILL.md
Normal file
586
skills/modern-angular/SKILL.md
Normal file
@@ -0,0 +1,586 @@
|
||||
---
|
||||
name: modern-angular-implementation
|
||||
description: Implement Angular 18+ features: Signals, standalone components, @defer blocks, SSR, zoneless change detection, new control flow syntax, and Material 3 integration.
|
||||
---
|
||||
|
||||
# Modern Angular Implementation Skill
|
||||
|
||||
## Angular Signals
|
||||
|
||||
### Basic Signals
|
||||
```typescript
|
||||
import { Component, signal, computed, effect } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-counter',
|
||||
standalone: true,
|
||||
template: `
|
||||
<button (click)="increment()">{{ count() }}</button>
|
||||
<p>Double: {{ double() }}</p>
|
||||
`
|
||||
})
|
||||
export class CounterComponent {
|
||||
// Writable signal
|
||||
count = signal(0);
|
||||
|
||||
// Computed signal (auto-updates)
|
||||
double = computed(() => this.count() * 2);
|
||||
|
||||
constructor() {
|
||||
// Effect (side effects)
|
||||
effect(() => {
|
||||
console.log('Count changed:', this.count());
|
||||
});
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.count.update(n => n + 1);
|
||||
// or: this.count.set(this.count() + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Signal Store Pattern
|
||||
```typescript
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserStore {
|
||||
// Private state signal
|
||||
private state = signal<{
|
||||
users: User[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}>({
|
||||
users: [],
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
// Public computed selectors
|
||||
readonly users = computed(() => this.state().users);
|
||||
readonly loading = computed(() => this.state().loading);
|
||||
readonly error = computed(() => this.state().error);
|
||||
readonly userCount = computed(() => this.users().length);
|
||||
|
||||
// Actions
|
||||
async loadUsers() {
|
||||
this.state.update(s => ({ ...s, loading: true }));
|
||||
try {
|
||||
const users = await this.http.get<User[]>('/api/users');
|
||||
this.state.update(s => ({ ...s, users, loading: false }));
|
||||
} catch (error) {
|
||||
this.state.update(s => ({
|
||||
...s,
|
||||
error: error.message,
|
||||
loading: false
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
addUser(user: User) {
|
||||
this.state.update(s => ({
|
||||
...s,
|
||||
users: [...s.users, user]
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Signals vs RxJS
|
||||
```typescript
|
||||
// ❌ OLD: RxJS BehaviorSubject
|
||||
private userSubject = new BehaviorSubject<User | null>(null);
|
||||
user$ = this.userSubject.asObservable();
|
||||
userName$ = this.user$.pipe(map(u => u?.name ?? 'Guest'));
|
||||
|
||||
ngOnDestroy() {
|
||||
this.userSubject.complete();
|
||||
}
|
||||
|
||||
// ✅ NEW: Angular Signals
|
||||
user = signal<User | null>(null);
|
||||
userName = computed(() => this.user()?.name ?? 'Guest');
|
||||
// No cleanup needed!
|
||||
```
|
||||
|
||||
## Standalone Components
|
||||
|
||||
### Basic Standalone Component
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule], // Import dependencies directly
|
||||
template: `
|
||||
<h1>Dashboard</h1>
|
||||
<router-outlet />
|
||||
`
|
||||
})
|
||||
export class DashboardComponent {}
|
||||
```
|
||||
|
||||
### Standalone Bootstrap
|
||||
```typescript
|
||||
// main.ts
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { routes } from './app/app.routes';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
// Add other providers
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Standalone Routes
|
||||
```typescript
|
||||
// app.routes.ts
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
loadChildren: () => import('./users/users.routes').then(m => m.USERS_ROUTES)
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
### Migration Command
|
||||
```bash
|
||||
# Automated migration to standalone
|
||||
ng generate @angular/core:standalone
|
||||
```
|
||||
|
||||
## Deferrable Views (@defer)
|
||||
|
||||
### Basic @defer
|
||||
```typescript
|
||||
@defer {
|
||||
<app-heavy-component />
|
||||
} @placeholder {
|
||||
<div class="loading-skeleton"></div>
|
||||
}
|
||||
```
|
||||
|
||||
### Defer with Triggers
|
||||
```typescript
|
||||
// On viewport (when visible)
|
||||
@defer (on viewport) {
|
||||
<app-chart [data]="data" />
|
||||
} @placeholder {
|
||||
<div class="chart-placeholder"></div>
|
||||
}
|
||||
|
||||
// On interaction (click or keydown)
|
||||
@defer (on interaction) {
|
||||
<app-advanced-editor />
|
||||
} @placeholder {
|
||||
<button>Load Editor</button>
|
||||
}
|
||||
|
||||
// On hover
|
||||
@defer (on hover) {
|
||||
<app-tooltip [content]="tooltipContent" />
|
||||
}
|
||||
|
||||
// On idle (browser idle)
|
||||
@defer (on idle) {
|
||||
<app-analytics-dashboard />
|
||||
}
|
||||
|
||||
// On timer
|
||||
@defer (on timer(5s)) {
|
||||
<app-promotional-banner />
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced @defer with States
|
||||
```typescript
|
||||
@defer (on interaction; prefetch on idle) {
|
||||
<app-video-player [src]="videoUrl" />
|
||||
} @loading (minimum 500ms; after 100ms) {
|
||||
<app-spinner />
|
||||
} @placeholder (minimum 1s) {
|
||||
<button>Load Video Player</button>
|
||||
} @error {
|
||||
<p>Failed to load video player</p>
|
||||
}
|
||||
```
|
||||
|
||||
### Strategic Deferment
|
||||
```typescript
|
||||
<div class="page">
|
||||
<!-- Critical content loads immediately -->
|
||||
<app-header />
|
||||
<app-hero-section />
|
||||
|
||||
<!-- Defer below-the-fold content -->
|
||||
@defer (on viewport) {
|
||||
<app-features-section />
|
||||
}
|
||||
|
||||
@defer (on viewport) {
|
||||
<app-testimonials />
|
||||
}
|
||||
|
||||
<!-- Defer interactive widgets -->
|
||||
@defer (on interaction; prefetch on idle) {
|
||||
<app-chat-widget />
|
||||
} @placeholder {
|
||||
<button class="chat-trigger">Chat with us</button>
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
## New Control Flow
|
||||
|
||||
### @if (replaces *ngIf)
|
||||
```typescript
|
||||
// OLD
|
||||
<div *ngIf="user">{{ user.name }}</div>
|
||||
<div *ngIf="user; else loading">{{ user.name }}</div>
|
||||
|
||||
// NEW
|
||||
@if (user) {
|
||||
<div>{{ user.name }}</div>
|
||||
}
|
||||
|
||||
@if (user) {
|
||||
<div>{{ user.name }}</div>
|
||||
} @else {
|
||||
<div>Loading...</div>
|
||||
}
|
||||
```
|
||||
|
||||
### @for (replaces *ngFor)
|
||||
```typescript
|
||||
// OLD
|
||||
<div *ngFor="let item of items; trackBy: trackById">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
|
||||
// NEW
|
||||
@for (item of items; track item.id) {
|
||||
<div>{{ item.name }}</div>
|
||||
} @empty {
|
||||
<p>No items found</p>
|
||||
}
|
||||
```
|
||||
|
||||
### @switch (replaces *ngSwitch)
|
||||
```typescript
|
||||
// OLD
|
||||
<div [ngSwitch]="status">
|
||||
<p *ngSwitchCase="'loading'">Loading...</p>
|
||||
<p *ngSwitchCase="'error'">Error occurred</p>
|
||||
<p *ngSwitchDefault>Success</p>
|
||||
</div>
|
||||
|
||||
// NEW
|
||||
@switch (status) {
|
||||
@case ('loading') {
|
||||
<p>Loading...</p>
|
||||
}
|
||||
@case ('error') {
|
||||
<p>Error occurred</p>
|
||||
}
|
||||
@default {
|
||||
<p>Success</p>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Combined Control Flow
|
||||
```typescript
|
||||
@if (users.length > 0) {
|
||||
<ul>
|
||||
@for (user of users; track user.id) {
|
||||
<li>
|
||||
{{ user.name }}
|
||||
@if (user.isAdmin) {
|
||||
<span class="badge">Admin</span>
|
||||
}
|
||||
</li>
|
||||
} @empty {
|
||||
<li>No users found</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p>Loading users...</p>
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Rendering (SSR)
|
||||
|
||||
### Enable SSR
|
||||
```bash
|
||||
# Add SSR to existing project
|
||||
ng add @angular/ssr
|
||||
|
||||
# Or create new project with SSR
|
||||
ng new my-app --ssr
|
||||
```
|
||||
|
||||
### SSR Configuration
|
||||
```typescript
|
||||
// app.config.ts
|
||||
import { ApplicationConfig } from '@angular/core';
|
||||
import { provideClientHydration } from '@angular/platform-browser';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideClientHydration() // Enable hydration
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### SSR-Safe Code
|
||||
```typescript
|
||||
import { Component, Inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
@Component({...})
|
||||
export class MapComponent {
|
||||
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
|
||||
|
||||
ngOnInit() {
|
||||
// Only run in browser
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
this.initializeMap();
|
||||
this.loadGoogleMapsAPI();
|
||||
}
|
||||
}
|
||||
|
||||
private initializeMap() {
|
||||
// Browser-specific code
|
||||
const map = new google.maps.Map(document.getElementById('map'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Transfer State (Avoid Duplicate Requests)
|
||||
```typescript
|
||||
import { Component, makeStateKey, TransferState } from '@angular/core';
|
||||
|
||||
const USERS_KEY = makeStateKey<User[]>('users');
|
||||
|
||||
@Component({...})
|
||||
export class UsersComponent {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private transferState: TransferState
|
||||
) {}
|
||||
|
||||
loadUsers() {
|
||||
// Check if data exists in transfer state (from SSR)
|
||||
const users = this.transferState.get(USERS_KEY, null);
|
||||
|
||||
if (users) {
|
||||
// Use cached data from SSR
|
||||
return of(users);
|
||||
}
|
||||
|
||||
// Fetch from API and cache for hydration
|
||||
return this.http.get<User[]>('/api/users').pipe(
|
||||
tap(users => this.transferState.set(USERS_KEY, users))
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Zoneless Change Detection
|
||||
|
||||
### Enable Zoneless
|
||||
```typescript
|
||||
// app.config.ts
|
||||
import { ApplicationConfig } from '@angular/core';
|
||||
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideExperimentalZonelessChangeDetection()
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### Zoneless-Compatible Code
|
||||
```typescript
|
||||
@Component({...})
|
||||
export class MyComponent {
|
||||
count = signal(0); // Signals work great with zoneless!
|
||||
|
||||
// Manual change detection when needed
|
||||
constructor(private cdr: ChangeDetectorRef) {}
|
||||
|
||||
onManualUpdate() {
|
||||
this.legacyProperty = 'new value';
|
||||
this.cdr.markForCheck(); // Trigger change detection manually
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Material 3
|
||||
|
||||
### Install Material 3
|
||||
```bash
|
||||
ng add @angular/material
|
||||
```
|
||||
|
||||
### Material 3 Theme
|
||||
```scss
|
||||
// styles.scss
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
$my-theme: mat.define-theme((
|
||||
color: (
|
||||
theme-type: light,
|
||||
primary: mat.$azure-palette,
|
||||
),
|
||||
));
|
||||
|
||||
html {
|
||||
@include mat.all-component-themes($my-theme);
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
html.dark-theme {
|
||||
$dark-theme: mat.define-theme((
|
||||
color: (
|
||||
theme-type: dark,
|
||||
primary: mat.$azure-palette,
|
||||
),
|
||||
));
|
||||
|
||||
@include mat.all-component-colors($dark-theme);
|
||||
}
|
||||
```
|
||||
|
||||
### Material 3 Components
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [MatButtonModule, MatCardModule, MatIconModule],
|
||||
template: `
|
||||
<mat-card appearance="outlined">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Material 3 Card</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p>Beautiful Material Design 3 components</p>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<button mat-button>Action</button>
|
||||
<button mat-raised-button color="primary">
|
||||
<mat-icon>favorite</mat-icon>
|
||||
Primary
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
`
|
||||
})
|
||||
export class MaterialCardComponent {}
|
||||
```
|
||||
|
||||
## Migration Patterns
|
||||
|
||||
### NgModule → Standalone
|
||||
```typescript
|
||||
// BEFORE: NgModule
|
||||
@NgModule({
|
||||
declarations: [UserComponent, UserListComponent],
|
||||
imports: [CommonModule, RouterModule],
|
||||
exports: [UserComponent]
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
// AFTER: Standalone
|
||||
export const USER_ROUTES: Routes = [{
|
||||
path: '',
|
||||
loadComponent: () => import('./user.component').then(m => m.UserComponent)
|
||||
}];
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule]
|
||||
})
|
||||
export class UserComponent {}
|
||||
```
|
||||
|
||||
### RxJS → Signals
|
||||
```typescript
|
||||
// BEFORE: RxJS
|
||||
class UserService {
|
||||
private usersSubject = new BehaviorSubject<User[]>([]);
|
||||
users$ = this.usersSubject.asObservable();
|
||||
|
||||
addUser(user: User) {
|
||||
const current = this.usersSubject.value;
|
||||
this.usersSubject.next([...current, user]);
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER: Signals
|
||||
class UserService {
|
||||
users = signal<User[]>([]);
|
||||
|
||||
addUser(user: User) {
|
||||
this.users.update(users => [...users, user]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Bundle Size Reduction with @defer
|
||||
```typescript
|
||||
// Can reduce initial bundle by 40-60%!
|
||||
@defer (on viewport) {
|
||||
<app-heavy-chart-library />
|
||||
}
|
||||
```
|
||||
|
||||
### Zoneless Performance Gains
|
||||
```typescript
|
||||
// 20-30% performance improvement
|
||||
provideExperimentalZonelessChangeDetection()
|
||||
```
|
||||
|
||||
### SSR Core Web Vitals
|
||||
```typescript
|
||||
// Dramatically improves LCP, FCP, TTFB
|
||||
provideClientHydration()
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Signals for Simple State** - Perfect for component-local reactive state
|
||||
2. **Keep RxJS for Complex Async** - Still best for HTTP, WebSockets, complex operators
|
||||
3. **Strategic @defer** - Don't defer critical content, be strategic
|
||||
4. **Gradual Migration** - Migrate to standalone incrementally
|
||||
5. **SSR-Safe Guards** - Always check isPlatformBrowser for DOM access
|
||||
6. **Zoneless-Ready** - Use Signals and OnPush to prepare for zoneless future
|
||||
|
||||
## Resources
|
||||
|
||||
- [Signals Documentation](https://angular.dev/guide/signals)
|
||||
- [Standalone Migration](https://angular.dev/reference/migrations/standalone)
|
||||
- [@defer Guide](https://angular.dev/guide/templates/defer)
|
||||
- [SSR Guide](https://angular.dev/guide/ssr)
|
||||
- [Material 3](https://material.angular.io)
|
||||
437
skills/routing/SKILL.md
Normal file
437
skills/routing/SKILL.md
Normal file
@@ -0,0 +1,437 @@
|
||||
---
|
||||
name: routing-performance-implementation
|
||||
description: Configure routing with lazy loading, implement route guards, set up preloading strategies, optimize change detection, analyze bundles, and implement performance optimizations.
|
||||
---
|
||||
|
||||
# Routing & Performance Implementation Skill
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Routing
|
||||
```typescript
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { HomeComponent, AboutComponent, NotFoundComponent } from './components';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: HomeComponent },
|
||||
{ path: 'about', component: AboutComponent },
|
||||
{ path: '**', component: NotFoundComponent }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
```
|
||||
|
||||
### Navigation
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<button (click)="goHome()">Home</button>
|
||||
<a routerLink="/about">About</a>
|
||||
<a routerLink="/users" [queryParams]="{ tab: 'active' }">Users</a>
|
||||
`
|
||||
})
|
||||
export class NavComponent {
|
||||
constructor(private router: Router) {}
|
||||
|
||||
goHome() {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Route Parameters
|
||||
```typescript
|
||||
const routes: Routes = [
|
||||
{ path: 'users/:id', component: UserDetailComponent },
|
||||
{ path: 'users/:id/posts/:postId', component: PostDetailComponent }
|
||||
];
|
||||
|
||||
// Component
|
||||
@Component({...})
|
||||
export class UserDetailComponent {
|
||||
userId!: string;
|
||||
|
||||
constructor(private route: ActivatedRoute) {
|
||||
this.route.params.subscribe(params => {
|
||||
this.userId = params['id'];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Or with snapshot
|
||||
ngOnInit() {
|
||||
const id = this.route.snapshot.params['id'];
|
||||
}
|
||||
```
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
### Feature Modules with Lazy Loading
|
||||
```typescript
|
||||
// app-routing.module.ts
|
||||
const routes: Routes = [
|
||||
{ path: '', component: HomeComponent },
|
||||
{
|
||||
path: 'users',
|
||||
loadChildren: () => import('./users/users.module').then(m => m.UsersModule)
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
loadChildren: () => import('./products/products.module').then(m => m.ProductsModule)
|
||||
}
|
||||
];
|
||||
|
||||
// users/users-routing.module.ts
|
||||
const routes: Routes = [
|
||||
{ path: '', component: UserListComponent },
|
||||
{ path: ':id', component: UserDetailComponent }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class UsersRoutingModule { }
|
||||
```
|
||||
|
||||
### Lazy Loading with Standalone Components
|
||||
```typescript
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: 'admin',
|
||||
loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES)
|
||||
}
|
||||
];
|
||||
|
||||
// admin/admin.routes.ts
|
||||
export const ADMIN_ROUTES: Routes = [
|
||||
{ path: '', component: AdminDashboardComponent },
|
||||
{ path: 'users', component: AdminUsersComponent }
|
||||
];
|
||||
```
|
||||
|
||||
## Route Guards
|
||||
|
||||
### CanActivate Guard
|
||||
```typescript
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<boolean> {
|
||||
return this.authService.isAuthenticated$.pipe(
|
||||
map(isAuth => {
|
||||
if (isAuth) return true;
|
||||
this.router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const routes: Routes = [
|
||||
{ path: 'admin', component: AdminComponent, canActivate: [AuthGuard] }
|
||||
];
|
||||
```
|
||||
|
||||
### CanDeactivate Guard
|
||||
```typescript
|
||||
export interface CanComponentDeactivate {
|
||||
canDeactivate: () => Observable<boolean> | boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
|
||||
canDeactivate(component: CanComponentDeactivate): Observable<boolean> | boolean {
|
||||
return component.canDeactivate();
|
||||
}
|
||||
}
|
||||
|
||||
// Component
|
||||
@Component({...})
|
||||
export class FormComponent implements CanComponentDeactivate {
|
||||
form!: FormGroup;
|
||||
|
||||
canDeactivate(): Observable<boolean> | boolean {
|
||||
return !this.form.dirty || confirm('Discard changes?');
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
{ path: 'form', component: FormComponent, canDeactivate: [CanDeactivateGuard] }
|
||||
```
|
||||
|
||||
### Resolve Guard
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class UserResolver implements Resolve<User> {
|
||||
constructor(private userService: UserService) {}
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot): Observable<User> {
|
||||
return this.userService.getUser(route.params['id']);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
{
|
||||
path: 'users/:id',
|
||||
component: UserDetailComponent,
|
||||
resolve: { user: UserResolver }
|
||||
}
|
||||
|
||||
// Component receives data
|
||||
@Component({...})
|
||||
export class UserDetailComponent {
|
||||
user!: User;
|
||||
|
||||
constructor(private route: ActivatedRoute) {
|
||||
this.route.data.subscribe(data => {
|
||||
this.user = data['user'];
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
```typescript
|
||||
// Navigation
|
||||
this.router.navigate(['/users'], {
|
||||
queryParams: {
|
||||
page: 1,
|
||||
sort: 'name',
|
||||
filter: 'active'
|
||||
}
|
||||
});
|
||||
|
||||
// Reading
|
||||
this.route.queryParams.subscribe(params => {
|
||||
const page = params['page'];
|
||||
const sort = params['sort'];
|
||||
});
|
||||
|
||||
// Template
|
||||
<a [routerLink]="['/users']" [queryParams]="{ page: 2, sort: 'name' }">
|
||||
Next Page
|
||||
</a>
|
||||
```
|
||||
|
||||
## Fragment (Hash)
|
||||
|
||||
```typescript
|
||||
// Navigation
|
||||
this.router.navigate(['/docs'], { fragment: 'section1' });
|
||||
|
||||
// Reading
|
||||
this.route.fragment.subscribe(fragment => {
|
||||
console.log('Fragment:', fragment);
|
||||
});
|
||||
|
||||
// Template
|
||||
<a routerLink="/docs" fragment="section1">Section 1</a>
|
||||
```
|
||||
|
||||
## Preloading Strategies
|
||||
|
||||
```typescript
|
||||
// Default: no preloading
|
||||
RouterModule.forRoot(routes);
|
||||
|
||||
// Preload all lazy modules
|
||||
RouterModule.forRoot(routes, {
|
||||
preloadingStrategy: PreloadAllModules
|
||||
});
|
||||
|
||||
// Custom preloading strategy
|
||||
@Injectable()
|
||||
export class SelectivePreloadingStrategy implements PreloadingStrategy {
|
||||
preload(route: Route, load: () => Observable<any>): Observable<any> {
|
||||
if (route.data && route.data['preload']) {
|
||||
return load();
|
||||
}
|
||||
return of(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const routes: Routes = [
|
||||
{ path: 'users', loadChildren: '...', data: { preload: true } }
|
||||
];
|
||||
|
||||
RouterModule.forRoot(routes, {
|
||||
preloadingStrategy: SelectivePreloadingStrategy
|
||||
})
|
||||
```
|
||||
|
||||
## Route Reuse Strategy
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class CustomRouteReuseStrategy implements RouteReuseStrategy {
|
||||
storedRoutes: { [key: string]: RouteData } = {};
|
||||
|
||||
shouldDetach(route: ActivatedRouteSnapshot): boolean {
|
||||
return route.data['cache'] === true;
|
||||
}
|
||||
|
||||
store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void {
|
||||
this.storedRoutes[route.url.join('/')] = { route, handle: detachedTree };
|
||||
}
|
||||
|
||||
shouldAttach(route: ActivatedRouteSnapshot): boolean {
|
||||
return !!this.storedRoutes[route.url.join('/')];
|
||||
}
|
||||
|
||||
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
|
||||
return this.storedRoutes[route.url.join('/')]?.handle || null;
|
||||
}
|
||||
|
||||
shouldReuseRoute(future: ActivatedRouteSnapshot, current: ActivatedRouteSnapshot): boolean {
|
||||
return future.routeConfig === current.routeConfig;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Code Splitting
|
||||
```typescript
|
||||
// Only load admin module when needed
|
||||
{
|
||||
path: 'admin',
|
||||
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
|
||||
}
|
||||
```
|
||||
|
||||
### Change Detection with Routes
|
||||
```typescript
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
template: `<router-outlet></router-outlet>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AppComponent { }
|
||||
```
|
||||
|
||||
### Scroll Position
|
||||
|
||||
```typescript
|
||||
// Scroll to top on route change
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'top'
|
||||
})
|
||||
|
||||
// Or custom scroll
|
||||
export class ScrollToTopComponent implements OnInit {
|
||||
constructor(private router: Router) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.router.events.pipe(
|
||||
filter(event => event instanceof NavigationEnd)
|
||||
).subscribe(() => {
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Auxiliary Routes
|
||||
```typescript
|
||||
// URL: /users/1(admin:admin-panel)
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet name="admin"></router-outlet>
|
||||
|
||||
// Navigation
|
||||
this.router.navigate([
|
||||
{ outlets: {
|
||||
primary: ['users', userId],
|
||||
admin: ['admin-panel']
|
||||
}}
|
||||
]);
|
||||
```
|
||||
|
||||
### Child Routes with Components
|
||||
```typescript
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: 'dashboard',
|
||||
component: DashboardComponent,
|
||||
children: [
|
||||
{ path: 'stats', component: StatsComponent },
|
||||
{ path: 'reports', component: ReportsComponent }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// DashboardComponent template
|
||||
<nav>
|
||||
<a routerLink="stats" routerLinkActive="active">Stats</a>
|
||||
<a routerLink="reports" routerLinkActive="active">Reports</a>
|
||||
</nav>
|
||||
<router-outlet></router-outlet>
|
||||
```
|
||||
|
||||
## Testing Routes
|
||||
|
||||
```typescript
|
||||
describe('Routing', () => {
|
||||
let router: Router;
|
||||
let location: Location;
|
||||
let fixture: ComponentFixture<AppComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppRoutingModule, AppComponent]
|
||||
}).compileComponents();
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
location = TestBed.inject(Location);
|
||||
fixture = TestBed.createComponent(AppComponent);
|
||||
});
|
||||
|
||||
it('should navigate to home', fakeAsync(() => {
|
||||
router.navigate(['']);
|
||||
tick();
|
||||
expect(location.path()).toBe('/');
|
||||
}));
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Lazy load features**: Reduce initial bundle size
|
||||
2. **Use route guards**: Control access and preload data
|
||||
3. **Implement RouteReuseStrategy**: Cache components when needed
|
||||
4. **Handle 404s**: Provide meaningful error pages
|
||||
5. **Query params for filters**: Keep state in URL
|
||||
6. **Preload strategically**: Balance performance vs initial load
|
||||
7. **Use fragments for anchors**: Scroll to page sections
|
||||
|
||||
## Resources
|
||||
|
||||
- [Angular Routing Guide](https://angular.io/guide/router)
|
||||
- [Route Guards](https://angular.io/guide/router-tutorial-toh)
|
||||
- [Lazy Loading](https://angular.io/guide/lazy-loading-ngmodules)
|
||||
339
skills/rxjs/SKILL.md
Normal file
339
skills/rxjs/SKILL.md
Normal file
@@ -0,0 +1,339 @@
|
||||
---
|
||||
name: rxjs-implementation
|
||||
description: Implement RxJS observables, apply operators, fix memory leaks with unsubscribe patterns, handle errors, create subjects, and build reactive data pipelines in Angular applications.
|
||||
---
|
||||
|
||||
# RxJS Implementation Skill
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Observable Basics
|
||||
```typescript
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// Create observable
|
||||
const observable = new Observable((observer) => {
|
||||
observer.next(1);
|
||||
observer.next(2);
|
||||
observer.next(3);
|
||||
observer.complete();
|
||||
});
|
||||
|
||||
// Subscribe
|
||||
const subscription = observable.subscribe({
|
||||
next: (value) => console.log(value),
|
||||
error: (error) => console.error(error),
|
||||
complete: () => console.log('Done')
|
||||
});
|
||||
|
||||
// Unsubscribe
|
||||
subscription.unsubscribe();
|
||||
```
|
||||
|
||||
### Common Operators
|
||||
```typescript
|
||||
import { map, filter, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
// Transformation
|
||||
data$.pipe(
|
||||
map(user => user.name),
|
||||
filter(name => name.length > 0)
|
||||
).subscribe(name => console.log(name));
|
||||
|
||||
// Higher-order
|
||||
userId$.pipe(
|
||||
switchMap(id => this.userService.getUser(id))
|
||||
).subscribe(user => console.log(user));
|
||||
```
|
||||
|
||||
## Subjects
|
||||
|
||||
### Subject Types
|
||||
```typescript
|
||||
import { Subject, BehaviorSubject, ReplaySubject } from 'rxjs';
|
||||
|
||||
// Subject - No initial value
|
||||
const subject = new Subject<string>();
|
||||
subject.next('hello');
|
||||
|
||||
// BehaviorSubject - Has initial value
|
||||
const behavior = new BehaviorSubject<string>('initial');
|
||||
behavior.next('new value');
|
||||
|
||||
// ReplaySubject - Replays N values
|
||||
const replay = new ReplaySubject<string>(3);
|
||||
replay.next('one');
|
||||
replay.next('two');
|
||||
```
|
||||
|
||||
### Service with Subject
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
private messageSubject = new Subject<string>();
|
||||
public message$ = this.messageSubject.asObservable();
|
||||
|
||||
notify(message: string) {
|
||||
this.messageSubject.next(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
constructor(private notification: NotificationService) {
|
||||
this.notification.message$.subscribe(msg => {
|
||||
console.log('Notification:', msg);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Transformation Operators
|
||||
|
||||
```typescript
|
||||
// map - Transform values
|
||||
source$.pipe(
|
||||
map(user => user.name)
|
||||
)
|
||||
|
||||
// switchMap - Switch to new observable (cancel previous)
|
||||
userId$.pipe(
|
||||
switchMap(id => this.userService.getUser(id))
|
||||
)
|
||||
|
||||
// mergeMap - Merge all results
|
||||
fileIds$.pipe(
|
||||
mergeMap(id => this.downloadFile(id))
|
||||
)
|
||||
|
||||
// concatMap - Sequential processing
|
||||
tasks$.pipe(
|
||||
concatMap(task => this.processTask(task))
|
||||
)
|
||||
|
||||
// exhaustMap - Ignore new while processing
|
||||
clicks$.pipe(
|
||||
exhaustMap(() => this.longRequest())
|
||||
)
|
||||
```
|
||||
|
||||
## Filtering Operators
|
||||
|
||||
```typescript
|
||||
// filter - Only pass matching values
|
||||
data$.pipe(
|
||||
filter(item => item.active)
|
||||
)
|
||||
|
||||
// first - Take first value
|
||||
data$.pipe(first())
|
||||
|
||||
// take - Take N values
|
||||
data$.pipe(take(5))
|
||||
|
||||
// takeUntil - Take until condition
|
||||
data$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
|
||||
// distinct - Filter duplicates
|
||||
data$.pipe(
|
||||
distinct(),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
// debounceTime - Wait N ms
|
||||
input$.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
```
|
||||
|
||||
## Combination Operators
|
||||
|
||||
```typescript
|
||||
import { combineLatest, merge, concat, zip } from 'rxjs';
|
||||
|
||||
// combineLatest - Latest from all
|
||||
combineLatest([user$, settings$, theme$]).pipe(
|
||||
map(([user, settings, theme]) => ({ user, settings, theme }))
|
||||
)
|
||||
|
||||
// merge - Values from any
|
||||
merge(click$, hover$, input$)
|
||||
|
||||
// concat - Sequential
|
||||
concat(request1$, request2$, request3$)
|
||||
|
||||
// zip - Wait for all
|
||||
zip(form1$, form2$, form3$)
|
||||
|
||||
// withLatestFrom - Combine with latest
|
||||
click$.pipe(
|
||||
withLatestFrom(user$),
|
||||
map(([click, user]) => ({ click, user }))
|
||||
)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
// catchError - Handle errors
|
||||
data$.pipe(
|
||||
catchError(error => {
|
||||
console.error('Error:', error);
|
||||
return of(defaultValue);
|
||||
})
|
||||
)
|
||||
|
||||
// retry - Retry on error
|
||||
request$.pipe(
|
||||
retry(3),
|
||||
catchError(error => throwError(error))
|
||||
)
|
||||
|
||||
// timeout - Timeout if no value
|
||||
request$.pipe(
|
||||
timeout(5000),
|
||||
catchError(error => of(null))
|
||||
)
|
||||
```
|
||||
|
||||
## Memory Leak Prevention
|
||||
|
||||
### Unsubscribe Pattern
|
||||
```typescript
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
ngOnInit() {
|
||||
this.data$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(data => {
|
||||
this.processData(data);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
```
|
||||
|
||||
### Async Pipe (Preferred)
|
||||
```typescript
|
||||
// Component
|
||||
export class UserComponent {
|
||||
user$ = this.userService.getUser(1);
|
||||
|
||||
constructor(private userService: UserService) {}
|
||||
}
|
||||
|
||||
// Template - Async pipe handles unsubscribe
|
||||
<div>{{ user$ | async as user }}
|
||||
<p>{{ user.name }}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Share Operator
|
||||
```typescript
|
||||
// Hot observable - Share single subscription
|
||||
readonly users$ = this.http.get('/api/users').pipe(
|
||||
shareReplay(1) // Cache last result
|
||||
);
|
||||
|
||||
// Now multiple subscriptions use same HTTP request
|
||||
this.users$.subscribe(users => {...});
|
||||
this.users$.subscribe(users => {...}); // Reuses cached
|
||||
```
|
||||
|
||||
### Scan for State
|
||||
```typescript
|
||||
// Accumulate state
|
||||
const counter$ = clicks$.pipe(
|
||||
scan((count) => count + 1, 0)
|
||||
)
|
||||
|
||||
// Complex state
|
||||
const appState$ = actions$.pipe(
|
||||
scan((state, action) => {
|
||||
switch(action.type) {
|
||||
case 'ADD_USER': return { ...state, users: [...state.users, action.user] };
|
||||
case 'DELETE_USER': return { ...state, users: state.users.filter(u => u.id !== action.id) };
|
||||
default: return state;
|
||||
}
|
||||
}, initialState)
|
||||
)
|
||||
```
|
||||
|
||||
### Forkjoin for Multiple Requests
|
||||
```typescript
|
||||
// Parallel requests
|
||||
forkJoin({
|
||||
users: this.userService.getUsers(),
|
||||
settings: this.settingService.getSettings(),
|
||||
themes: this.themeService.getThemes()
|
||||
}).subscribe(({ users, settings, themes }) => {
|
||||
console.log('All loaded:', users, settings, themes);
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Observables
|
||||
|
||||
```typescript
|
||||
import { marbles } from 'rxjs-marbles';
|
||||
|
||||
it('should map values correctly', marbles((m) => {
|
||||
const source = m.hot('a-b-|', { a: 1, b: 2 });
|
||||
const expected = m.cold('x-y-|', { x: 2, y: 4 });
|
||||
|
||||
const result = source.pipe(
|
||||
map(x => x * 2)
|
||||
);
|
||||
|
||||
m.expect(result).toBeObservable(expected);
|
||||
}));
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always unsubscribe**: Use takeUntil or async pipe
|
||||
2. **Use higher-order operators**: switchMap, mergeMap, etc.
|
||||
3. **Avoid nested subscriptions**: Use operators instead
|
||||
4. **Share subscriptions**: Use share/shareReplay for expensive operations
|
||||
5. **Handle errors**: Always include catchError
|
||||
6. **Type your observables**: `Observable<User>` not just `Observable`
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong - Creates multiple subscriptions
|
||||
this.data$.subscribe(d => {
|
||||
this.data$.subscribe(d2 => {
|
||||
// nested subscriptions!
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ Correct - Use switchMap
|
||||
this.data$.pipe(
|
||||
switchMap(d => this.otherService.fetch(d))
|
||||
).subscribe(result => {
|
||||
// handled
|
||||
});
|
||||
|
||||
// ❌ Wrong - Memory leak
|
||||
ngOnInit() {
|
||||
this.data$.subscribe(data => this.data = data);
|
||||
}
|
||||
|
||||
// ✅ Correct - Unsubscribe or async
|
||||
ngOnInit() {
|
||||
this.data$ = this.service.getData();
|
||||
}
|
||||
// In template: {{ data$ | async }}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [RxJS Documentation](https://rxjs.dev/)
|
||||
- [Interactive Diagrams](https://rxmarbles.com/)
|
||||
- [RxJS Operators](https://rxjs.dev/api)
|
||||
470
skills/state-management/SKILL.md
Normal file
470
skills/state-management/SKILL.md
Normal file
@@ -0,0 +1,470 @@
|
||||
---
|
||||
name: state-implementation
|
||||
description: Implement NgRx store with actions and reducers, build selectors, create effects for async operations, configure entity adapters, and integrate HTTP APIs with state management.
|
||||
---
|
||||
|
||||
# State Implementation Skill
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Simple Service-Based State
|
||||
```typescript
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserStore {
|
||||
private usersSubject = new BehaviorSubject<User[]>([]);
|
||||
users$ = this.usersSubject.asObservable();
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
loadUsers() {
|
||||
this.http.get<User[]>('/api/users').subscribe(
|
||||
users => this.usersSubject.next(users)
|
||||
);
|
||||
}
|
||||
|
||||
addUser(user: User) {
|
||||
this.http.post<User>('/api/users', user).subscribe(
|
||||
newUser => {
|
||||
const current = this.usersSubject.value;
|
||||
this.usersSubject.next([...current, newUser]);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
export class UserListComponent {
|
||||
users$ = this.userStore.users$;
|
||||
|
||||
constructor(private userStore: UserStore) {}
|
||||
}
|
||||
```
|
||||
|
||||
### NgRx Basics
|
||||
```typescript
|
||||
// 1. Define actions
|
||||
export const loadUsers = createAction('[User] Load Users');
|
||||
export const loadUsersSuccess = createAction(
|
||||
'[User] Load Users Success',
|
||||
props<{ users: User[] }>()
|
||||
);
|
||||
export const loadUsersError = createAction(
|
||||
'[User] Load Users Error',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// 2. Create reducer
|
||||
const initialState: UserState = { users: [], loading: false };
|
||||
|
||||
export const userReducer = createReducer(
|
||||
initialState,
|
||||
on(loadUsers, state => ({ ...state, loading: true })),
|
||||
on(loadUsersSuccess, (state, { users }) => ({
|
||||
...state,
|
||||
users,
|
||||
loading: false
|
||||
})),
|
||||
on(loadUsersError, (state, { error }) => ({
|
||||
...state,
|
||||
error,
|
||||
loading: false
|
||||
}))
|
||||
);
|
||||
|
||||
// 3. Create effect
|
||||
@Injectable()
|
||||
export class UserEffects {
|
||||
loadUsers$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(loadUsers),
|
||||
switchMap(() =>
|
||||
this.userService.getUsers().pipe(
|
||||
map(users => loadUsersSuccess({ users })),
|
||||
catchError(error => of(loadUsersError({ error })))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
constructor(
|
||||
private actions$: Actions,
|
||||
private userService: UserService
|
||||
) {}
|
||||
}
|
||||
|
||||
// 4. Use in component
|
||||
@Component({...})
|
||||
export class UserListComponent {
|
||||
users$ = this.store.select(selectUsers);
|
||||
loading$ = this.store.select(selectLoading);
|
||||
|
||||
constructor(private store: Store) {
|
||||
this.store.dispatch(loadUsers());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## NgRx Core Concepts
|
||||
|
||||
### Store
|
||||
```typescript
|
||||
// Dispatch action
|
||||
this.store.dispatch(loadUsers());
|
||||
|
||||
// Select state
|
||||
this.store.select(selectUsers).subscribe(users => {
|
||||
console.log(users);
|
||||
});
|
||||
|
||||
// Select with observable
|
||||
this.users$ = this.store.select(selectUsers);
|
||||
|
||||
// Multiple selects
|
||||
this.store.select(selectUsers, selectLoading).subscribe(([users, loading]) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Selectors
|
||||
```typescript
|
||||
// Feature selector
|
||||
export const selectUserState = createFeatureSelector<UserState>('users');
|
||||
|
||||
// Select from feature
|
||||
export const selectUsers = createSelector(
|
||||
selectUserState,
|
||||
state => state.users
|
||||
);
|
||||
|
||||
// Selector composition
|
||||
export const selectActiveUsers = createSelector(
|
||||
selectUsers,
|
||||
users => users.filter(u => u.active)
|
||||
);
|
||||
|
||||
// Memoized selector
|
||||
export const selectUserById = (id: number) => createSelector(
|
||||
selectUsers,
|
||||
users => users.find(u => u.id === id)
|
||||
);
|
||||
|
||||
// With props
|
||||
export const selectUsersByRole = createSelector(
|
||||
selectUsers,
|
||||
(users: User[], { role }: { role: string }) =>
|
||||
users.filter(u => u.role === role)
|
||||
);
|
||||
|
||||
// Usage with props
|
||||
this.store.select(selectUsersByRole, { role: 'admin' });
|
||||
```
|
||||
|
||||
### Effects
|
||||
```typescript
|
||||
// Side effect - HTTP call
|
||||
@Injectable()
|
||||
export class UserEffects {
|
||||
loadUsers$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UserActions.loadUsers),
|
||||
switchMap(() =>
|
||||
this.userService.getUsers().pipe(
|
||||
map(users => UserActions.loadUsersSuccess({ users })),
|
||||
catchError(error => of(UserActions.loadUsersError({ error })))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Non-dispatching effect
|
||||
logActions$ = createEffect(
|
||||
() => this.actions$.pipe(
|
||||
tap(action => console.log(action))
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
constructor(
|
||||
private actions$: Actions,
|
||||
private userService: UserService
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Adapter
|
||||
|
||||
### Setup
|
||||
```typescript
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const adapter = createEntityAdapter<User>({
|
||||
selectId: (user: User) => user.id,
|
||||
sortComparer: (a: User, b: User) => a.name.localeCompare(b.name)
|
||||
});
|
||||
|
||||
export interface UserState extends EntityState<User> {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState = adapter.getInitialState({
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
```
|
||||
|
||||
### Reducer with Adapter
|
||||
```typescript
|
||||
export const userReducer = createReducer(
|
||||
initialState,
|
||||
on(loadUsers, state => ({ ...state, loading: true })),
|
||||
on(loadUsersSuccess, (state, { users }) =>
|
||||
adapter.setAll(users, { ...state, loading: false })
|
||||
),
|
||||
on(addUserSuccess, (state, { user }) =>
|
||||
adapter.addOne(user, state)
|
||||
),
|
||||
on(updateUserSuccess, (state, { user }) =>
|
||||
adapter.updateOne({ id: user.id, changes: user }, state)
|
||||
),
|
||||
on(deleteUserSuccess, (state, { id }) =>
|
||||
adapter.removeOne(id, state)
|
||||
)
|
||||
);
|
||||
|
||||
// Export selectors
|
||||
export const {
|
||||
selectIds,
|
||||
selectEntities,
|
||||
selectAll,
|
||||
selectTotal
|
||||
} = adapter.getSelectors(selectUserState);
|
||||
```
|
||||
|
||||
## Facade Pattern
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class UserFacade {
|
||||
users$ = this.store.select(selectAllUsers);
|
||||
loading$ = this.store.select(selectUsersLoading);
|
||||
error$ = this.store.select(selectUsersError);
|
||||
|
||||
constructor(private store: Store) {}
|
||||
|
||||
loadUsers() {
|
||||
this.store.dispatch(loadUsers());
|
||||
}
|
||||
|
||||
addUser(user: User) {
|
||||
this.store.dispatch(addUser({ user }));
|
||||
}
|
||||
|
||||
updateUser(id: number, changes: Partial<User>) {
|
||||
this.store.dispatch(updateUser({ id, changes }));
|
||||
}
|
||||
|
||||
deleteUser(id: number) {
|
||||
this.store.dispatch(deleteUser({ id }));
|
||||
}
|
||||
}
|
||||
|
||||
// Component usage simplified
|
||||
@Component({...})
|
||||
export class UserListComponent {
|
||||
users$ = this.userFacade.users$;
|
||||
loading$ = this.userFacade.loading$;
|
||||
|
||||
constructor(private userFacade: UserFacade) {
|
||||
this.userFacade.loadUsers();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Angular Signals
|
||||
|
||||
```typescript
|
||||
import { signal, computed, effect } from '@angular/core';
|
||||
|
||||
// Create signal
|
||||
const count = signal(0);
|
||||
|
||||
// Read value
|
||||
console.log(count()); // 0
|
||||
|
||||
// Update value
|
||||
count.set(1);
|
||||
count.update(c => c + 1);
|
||||
|
||||
// Computed value
|
||||
const doubled = computed(() => count() * 2);
|
||||
|
||||
// Effect
|
||||
effect(() => {
|
||||
console.log(`Count is ${count()}`);
|
||||
console.log(`Doubled is ${doubled()}`);
|
||||
});
|
||||
|
||||
// Signal-based state
|
||||
@Component({...})
|
||||
export class CounterComponent {
|
||||
count = signal(0);
|
||||
doubled = computed(() => this.count() * 2);
|
||||
|
||||
increment() {
|
||||
this.count.update(c => c + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Integration
|
||||
|
||||
### HttpClient with Interceptor
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
const token = this.authService.getToken();
|
||||
const authReq = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
return next.handle(authReq);
|
||||
}
|
||||
}
|
||||
|
||||
// Register
|
||||
@NgModule({
|
||||
providers: [
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AuthInterceptor,
|
||||
multi: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class AppModule { }
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class CachingService {
|
||||
private cache = new Map<string, any>();
|
||||
|
||||
get<T>(key: string, request: Observable<T>, ttl: number = 3600000): Observable<T> {
|
||||
if (this.cache.has(key)) {
|
||||
return of(this.cache.get(key));
|
||||
}
|
||||
|
||||
return request.pipe(
|
||||
tap(data => {
|
||||
this.cache.set(key, data);
|
||||
setTimeout(() => this.cache.delete(key), ttl);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
getUsers() {
|
||||
return this.caching.get(
|
||||
'users',
|
||||
this.http.get<User[]>('/api/users'),
|
||||
5 * 60 * 1000 // 5 minutes
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing State
|
||||
|
||||
```typescript
|
||||
describe('User Store', () => {
|
||||
let store: MockStore;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [StoreModule.forRoot({ users: userReducer })]
|
||||
});
|
||||
store = TestBed.inject(Store) as MockStore;
|
||||
});
|
||||
|
||||
it('should load users', () => {
|
||||
const action = loadUsers();
|
||||
const completion = loadUsersSuccess({ users: mockUsers });
|
||||
|
||||
const effect$ = new UserEffects(
|
||||
hot('a', { a: action }),
|
||||
mockUserService
|
||||
).loadUsers$;
|
||||
|
||||
const result = cold('b', { b: completion });
|
||||
expect(effect$).toBeObservable(result);
|
||||
});
|
||||
|
||||
it('should select users', (done) => {
|
||||
store.setState({ users: { users: mockUsers } });
|
||||
store.select(selectUsers).subscribe(users => {
|
||||
expect(users).toEqual(mockUsers);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Normalize State**: Flat structure, avoid nesting
|
||||
2. **Single Responsibility**: Each reducer handles one feature
|
||||
3. **Use Facades**: Simplify component-store interaction
|
||||
4. **Memoize Selectors**: Prevent unnecessary recalculations
|
||||
5. **Handle Errors**: Always include error states
|
||||
6. **Lazy Load Stores**: Register feature stores when needed
|
||||
7. **Time-Travel Debugging**: Use Redux DevTools
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Composition Pattern
|
||||
```typescript
|
||||
// Combine multiple stores
|
||||
@Injectable()
|
||||
export class AppFacade {
|
||||
users$ = this.userFacade.users$;
|
||||
products$ = this.productFacade.products$;
|
||||
cart$ = this.cartFacade.cart$;
|
||||
|
||||
constructor(
|
||||
private userFacade: UserFacade,
|
||||
private productFacade: ProductFacade,
|
||||
private cartFacade: CartFacade
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### Feature Flags
|
||||
```typescript
|
||||
export const selectFeatureFlags = createFeatureSelector<FeatureFlags>('features');
|
||||
export const selectFeatureEnabled = (feature: string) => createSelector(
|
||||
selectFeatureFlags,
|
||||
flags => flags[feature]?.enabled ?? false
|
||||
);
|
||||
|
||||
// Component
|
||||
<div *ngIf="featureEnabled$ | async">New Feature</div>
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [NgRx Documentation](https://ngrx.io/)
|
||||
- [Entity Adapter](https://ngrx.io/guide/entity)
|
||||
- [DevTools](https://github.com/reduxjs/redux-devtools-extension)
|
||||
480
skills/testing/SKILL.md
Normal file
480
skills/testing/SKILL.md
Normal file
@@ -0,0 +1,480 @@
|
||||
---
|
||||
name: testing-deployment-implementation
|
||||
description: Write unit tests for components and services, implement E2E tests with Cypress, set up test mocks, optimize production builds, configure CI/CD pipelines, and deploy to production platforms.
|
||||
---
|
||||
|
||||
# Testing & Deployment Implementation Skill
|
||||
|
||||
## Unit Testing Basics
|
||||
|
||||
### TestBed Setup
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [UserService]
|
||||
});
|
||||
|
||||
service = TestBed.inject(UserService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Component Testing
|
||||
```typescript
|
||||
describe('UserListComponent', () => {
|
||||
let component: UserListComponent;
|
||||
let fixture: ComponentFixture<UserListComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [UserListComponent],
|
||||
imports: [CommonModule, HttpClientTestingModule],
|
||||
providers: [UserService]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UserListComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should display users', () => {
|
||||
const mockUsers: User[] = [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' }
|
||||
];
|
||||
|
||||
component.users = mockUsers;
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const userElements = compiled.querySelectorAll('.user-item');
|
||||
expect(userElements.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should call service on init', () => {
|
||||
const userService = TestBed.inject(UserService);
|
||||
spyOn(userService, 'getUsers').and.returnValue(of([]));
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(userService.getUsers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Operations
|
||||
```typescript
|
||||
// Using fakeAsync and tick
|
||||
it('should load users after delay', fakeAsync(() => {
|
||||
const userService = TestBed.inject(UserService);
|
||||
spyOn(userService, 'getUsers').and.returnValue(
|
||||
of([{ id: 1, name: 'John' }]).pipe(delay(1000))
|
||||
);
|
||||
|
||||
component.ngOnInit();
|
||||
expect(component.users.length).toBe(0);
|
||||
|
||||
tick(1000);
|
||||
expect(component.users.length).toBe(1);
|
||||
}));
|
||||
|
||||
// Using waitForAsync
|
||||
it('should handle async operations', waitForAsync(() => {
|
||||
const userService = TestBed.inject(UserService);
|
||||
spyOn(userService, 'getUsers').and.returnValue(
|
||||
of([{ id: 1, name: 'John' }])
|
||||
);
|
||||
|
||||
component.ngOnInit();
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.users.length).toBe(1);
|
||||
});
|
||||
}));
|
||||
```
|
||||
|
||||
## Mocking Services
|
||||
|
||||
### HTTP Mocking
|
||||
```typescript
|
||||
it('should fetch users from API', () => {
|
||||
const mockUsers: User[] = [{ id: 1, name: 'John' }];
|
||||
|
||||
service.getUsers().subscribe(users => {
|
||||
expect(users.length).toBe(1);
|
||||
expect(users[0].name).toBe('John');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/users');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockUsers);
|
||||
});
|
||||
|
||||
// POST with error handling
|
||||
it('should handle errors', () => {
|
||||
service.createUser({ name: 'Jane' }).subscribe(
|
||||
() => fail('should not succeed'),
|
||||
(error) => expect(error.status).toBe(400)
|
||||
);
|
||||
|
||||
const req = httpMock.expectOne('/api/users');
|
||||
req.flush('Invalid user', { status: 400, statusText: 'Bad Request' });
|
||||
});
|
||||
```
|
||||
|
||||
### Service Mocking
|
||||
```typescript
|
||||
class MockUserService {
|
||||
getUsers() {
|
||||
return of([
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-test',
|
||||
template: '<div>{{ (users$ | async)?.length }}</div>'
|
||||
})
|
||||
class TestComponent {
|
||||
users$ = this.userService.getUsers();
|
||||
constructor(private userService: UserService) {}
|
||||
}
|
||||
|
||||
describe('TestComponent with Mock', () => {
|
||||
let component: TestComponent;
|
||||
let fixture: ComponentFixture<TestComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [TestComponent],
|
||||
providers: [
|
||||
{ provide: UserService, useClass: MockUserService }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render users', () => {
|
||||
const div = fixture.nativeElement.querySelector('div');
|
||||
expect(div.textContent).toContain('2');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## E2E Testing with Cypress
|
||||
|
||||
### Basic E2E Test
|
||||
```typescript
|
||||
describe('User List Page', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/users');
|
||||
});
|
||||
|
||||
it('should display user list', () => {
|
||||
cy.get('[data-testid="user-item"]')
|
||||
.should('have.length', 10);
|
||||
});
|
||||
|
||||
it('should filter users by name', () => {
|
||||
cy.get('[data-testid="search-input"]')
|
||||
.type('John');
|
||||
|
||||
cy.get('[data-testid="user-item"]')
|
||||
.should('have.length', 1)
|
||||
.should('contain', 'John');
|
||||
});
|
||||
|
||||
it('should navigate to user detail', () => {
|
||||
cy.get('[data-testid="user-item"]').first().click();
|
||||
cy.location('pathname').should('include', '/users/');
|
||||
cy.get('[data-testid="user-detail"]').should('be.visible');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Page Object Model
|
||||
```typescript
|
||||
// user.po.ts
|
||||
export class UserPage {
|
||||
navigateTo(path: string = '/users') {
|
||||
cy.visit(path);
|
||||
return this;
|
||||
}
|
||||
|
||||
getUsers() {
|
||||
return cy.get('[data-testid="user-item"]');
|
||||
}
|
||||
|
||||
getUserByName(name: string) {
|
||||
return cy.get('[data-testid="user-item"]').contains(name);
|
||||
}
|
||||
|
||||
clickUser(index: number) {
|
||||
this.getUsers().eq(index).click();
|
||||
return this;
|
||||
}
|
||||
|
||||
searchUser(query: string) {
|
||||
cy.get('[data-testid="search-input"]').type(query);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// Test using PO
|
||||
describe('User Page', () => {
|
||||
const page = new UserPage();
|
||||
|
||||
beforeEach(() => {
|
||||
page.navigateTo();
|
||||
});
|
||||
|
||||
it('should find user by name', () => {
|
||||
page.searchUser('John');
|
||||
page.getUsers().should('have.length', 1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Build Optimization
|
||||
|
||||
### AOT Compilation
|
||||
```typescript
|
||||
// angular.json
|
||||
{
|
||||
"projects": {
|
||||
"app": {
|
||||
"architect": {
|
||||
"build": {
|
||||
"options": {
|
||||
"aot": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"optimization": true,
|
||||
"buildOptimizer": true,
|
||||
"namedChunks": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Bundle Analysis
|
||||
```bash
|
||||
# Install webpack-bundle-analyzer
|
||||
npm install --save-dev webpack-bundle-analyzer
|
||||
|
||||
# Run analysis
|
||||
ng build --stats-json
|
||||
webpack-bundle-analyzer dist/app/stats.json
|
||||
```
|
||||
|
||||
### Code Splitting
|
||||
```typescript
|
||||
// app-routing.module.ts
|
||||
const routes: Routes = [
|
||||
{ path: '', component: HomeComponent },
|
||||
{
|
||||
path: 'admin',
|
||||
loadChildren: () =>
|
||||
import('./admin/admin.module').then(m => m.AdminModule)
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
loadChildren: () =>
|
||||
import('./users/users.module').then(m => m.UsersModule)
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
# Build for production
|
||||
ng build --configuration production
|
||||
|
||||
# Output directory
|
||||
dist/app/
|
||||
|
||||
# Serve locally
|
||||
npx http-server dist/app/
|
||||
```
|
||||
|
||||
### Deployment Targets
|
||||
|
||||
**Firebase:**
|
||||
```bash
|
||||
npm install -g firebase-tools
|
||||
firebase login
|
||||
firebase init hosting
|
||||
firebase deploy
|
||||
```
|
||||
|
||||
**Netlify:**
|
||||
```bash
|
||||
npm run build
|
||||
# Drag and drop dist/ folder to Netlify
|
||||
# Or use CLI:
|
||||
npm install -g netlify-cli
|
||||
netlify deploy --prod --dir=dist/app
|
||||
```
|
||||
|
||||
**GitHub Pages:**
|
||||
```bash
|
||||
ng build --output-path docs --base-href /repo-name/
|
||||
git add docs/
|
||||
git commit -m "Deploy to GitHub Pages"
|
||||
git push
|
||||
# Enable in repository settings
|
||||
```
|
||||
|
||||
**Docker:**
|
||||
```dockerfile
|
||||
# Build stage
|
||||
FROM node:18 as build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Serve stage
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist/app /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
## CI/CD Pipelines
|
||||
|
||||
### GitHub Actions
|
||||
```yaml
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: npm run test -- --watch=false --code-coverage
|
||||
|
||||
- name: E2E Test
|
||||
run: npm run e2e
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
|
||||
- name: Deploy
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: npm run deploy
|
||||
```
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
### Core Web Vitals
|
||||
```typescript
|
||||
// Using web-vitals library
|
||||
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
|
||||
|
||||
getCLS(console.log);
|
||||
getFID(console.log);
|
||||
getFCP(console.log);
|
||||
getLCP(console.log);
|
||||
getTTFB(console.log);
|
||||
```
|
||||
|
||||
### Error Tracking (Sentry)
|
||||
```typescript
|
||||
import * as Sentry from "@sentry/angular";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing(),
|
||||
new Sentry.Replay(),
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
});
|
||||
|
||||
@NgModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ErrorHandler,
|
||||
useValue: Sentry.createErrorHandler(),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
1. **Arrange-Act-Assert**: Clear test structure
|
||||
2. **One Assertion per Test**: Keep tests focused
|
||||
3. **Test Behavior**: Not implementation details
|
||||
4. **Use Page Objects**: For E2E tests
|
||||
5. **Mock External Dependencies**: Services, HTTP
|
||||
6. **Test Error Cases**: Invalid input, failures
|
||||
7. **Aim for 80% Coverage**: Don't obsess over 100%
|
||||
|
||||
## Coverage Report
|
||||
```bash
|
||||
# Generate coverage report
|
||||
ng test --code-coverage
|
||||
|
||||
# View report
|
||||
open coverage/index.html
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Jasmine Documentation](https://jasmine.github.io/)
|
||||
- [Angular Testing Guide](https://angular.io/guide/testing)
|
||||
- [Cypress Documentation](https://docs.cypress.io/)
|
||||
- [Testing Best Practices](https://angular.io/guide/testing-best-practices)
|
||||
251
skills/typescript/SKILL.md
Normal file
251
skills/typescript/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: typescript-implementation
|
||||
description: Implement TypeScript patterns, convert JavaScript to TypeScript, add type annotations, create generics, implement decorators, and enforce strict type safety in Angular projects.
|
||||
---
|
||||
|
||||
# TypeScript Implementation Skill
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Types
|
||||
```typescript
|
||||
// Primitive types
|
||||
let name: string = "Angular";
|
||||
let version: number = 18;
|
||||
let active: boolean = true;
|
||||
|
||||
// Union types
|
||||
let id: string | number;
|
||||
|
||||
// Type aliases
|
||||
type User = {
|
||||
name: string;
|
||||
age: number;
|
||||
};
|
||||
```
|
||||
|
||||
### Interfaces and Generics
|
||||
```typescript
|
||||
interface Component {
|
||||
render(): string;
|
||||
}
|
||||
|
||||
// Generic interface
|
||||
interface Repository<T> {
|
||||
getAll(): T[];
|
||||
getById(id: number): T | undefined;
|
||||
}
|
||||
|
||||
class UserRepository implements Repository<User> {
|
||||
getAll(): User[] { /* ... */ }
|
||||
getById(id: number): User | undefined { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Decorators (Essential for Angular)
|
||||
```typescript
|
||||
// Class decorator
|
||||
function Component(config: any) {
|
||||
return function(target: any) {
|
||||
target.prototype.selector = config.selector;
|
||||
};
|
||||
}
|
||||
|
||||
// Method decorator
|
||||
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
descriptor.value = function(...args: any[]) {
|
||||
console.log(`Calling ${propertyKey} with:`, args);
|
||||
return originalMethod.apply(this, args);
|
||||
};
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
// Parameter decorator
|
||||
function Required(target: any, propertyKey: string, parameterIndex: number) {
|
||||
// Validation logic
|
||||
}
|
||||
```
|
||||
|
||||
## Essential Concepts
|
||||
|
||||
### Advanced Types
|
||||
|
||||
**Utility Types:**
|
||||
- `Partial<T>` - Make all properties optional
|
||||
- `Required<T>` - Make all properties required
|
||||
- `Readonly<T>` - Make all properties readonly
|
||||
- `Record<K, T>` - Object with specific key types
|
||||
- `Pick<T, K>` - Select specific properties
|
||||
- `Omit<T, K>` - Exclude specific properties
|
||||
|
||||
**Conditional Types:**
|
||||
```typescript
|
||||
type IsString<T> = T extends string ? true : false;
|
||||
type A = IsString<"hello">; // true
|
||||
type B = IsString<number>; // false
|
||||
```
|
||||
|
||||
**Mapped Types:**
|
||||
```typescript
|
||||
type Getters<T> = {
|
||||
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
|
||||
};
|
||||
```
|
||||
|
||||
### Generic Constraints
|
||||
```typescript
|
||||
// Extend constraint
|
||||
function processUser<T extends User>(user: T) {
|
||||
console.log(user.name); // OK, T has 'name'
|
||||
}
|
||||
|
||||
// Keyof constraint
|
||||
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
|
||||
return obj[key];
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Type Guards
|
||||
```typescript
|
||||
// Type predicate
|
||||
function isUser(value: unknown): value is User {
|
||||
return typeof value === 'object' && value !== null && 'name' in value;
|
||||
}
|
||||
|
||||
// Discriminated unions
|
||||
type Shape =
|
||||
| { kind: 'circle'; radius: number }
|
||||
| { kind: 'square'; side: number };
|
||||
|
||||
function getArea(shape: Shape) {
|
||||
switch (shape.kind) {
|
||||
case 'circle': return Math.PI * shape.radius ** 2;
|
||||
case 'square': return shape.side ** 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Module System
|
||||
```typescript
|
||||
// Export
|
||||
export interface User { name: string; }
|
||||
export const API_URL = 'https://api.example.com';
|
||||
|
||||
// Import
|
||||
import { User, API_URL } from './types';
|
||||
import * as Types from './types'; // Namespace import
|
||||
```
|
||||
|
||||
## Async Programming
|
||||
```typescript
|
||||
// Promises
|
||||
async function fetchUser(): Promise<User> {
|
||||
const response = await fetch('/api/users/1');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Error handling
|
||||
async function safeRequest() {
|
||||
try {
|
||||
const result = await fetchUser();
|
||||
} catch (error) {
|
||||
console.error('Request failed:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Avoid `any`**: Use `unknown` and type guards instead
|
||||
2. **Use strict mode**: Enable `strict` in tsconfig.json
|
||||
3. **Leverage utility types**: Reduce code duplication
|
||||
4. **Document complex types**: Use JSDoc for clarity
|
||||
5. **Test type definitions**: Use type-level tests
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Result Type Pattern
|
||||
```typescript
|
||||
type Result<T, E> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
function createUser(data: any): Result<User, string> {
|
||||
try {
|
||||
// validation and creation
|
||||
return { ok: true, value: user };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Builder Pattern
|
||||
```typescript
|
||||
class QueryBuilder<T> {
|
||||
private query: any = {};
|
||||
|
||||
where(field: keyof T, value: any): this {
|
||||
this.query[field] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return this.query;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Real-World Angular Examples
|
||||
|
||||
### Service Type Safety
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getUser(id: number): Observable<User> {
|
||||
return this.http.get<User>(`/api/users/${id}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component Props
|
||||
```typescript
|
||||
interface ComponentProps {
|
||||
title: string;
|
||||
items: Item[];
|
||||
onSelect: (item: Item) => void;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-list',
|
||||
template: `...`
|
||||
})
|
||||
export class ListComponent implements ComponentProps {
|
||||
@Input() title!: string;
|
||||
@Input() items: Item[] = [];
|
||||
@Output() itemSelected = new EventEmitter<Item>();
|
||||
|
||||
onSelect(item: Item) {
|
||||
this.itemSelected.emit(item);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
- Use `const` assertions for literal types
|
||||
- Leverage structural typing for flexibility
|
||||
- Use discriminated unions for safe pattern matching
|
||||
- Avoid circular type dependencies
|
||||
- Use `omit` to reduce property access
|
||||
|
||||
## Resources
|
||||
|
||||
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
|
||||
- [Advanced Types](https://www.typescriptlang.org/docs/handbook/2/types-from-types.html)
|
||||
- [TypeScript Playground](https://www.typescriptlang.org/play)
|
||||
Reference in New Issue
Block a user