30 Commits

Author SHA1 Message Date
mnky 20e6b1d33b Merge pull request 'fix: add nginx SPA fallback' (#8) from build/add-push-drone-pipeleine-2 into main
continuous-integration/drone/push Build is passing
Reviewed-on: #8
2026-03-09 11:51:40 +00:00
Dennis Hundertmark 3cf3da9886 fix: add nginx SPA fallback
continuous-integration/drone/pr Build is passing
2026-03-09 12:51:03 +01:00
mnky 0521385d30 Merge pull request 'ci: use netrc for manifest repo push' (#7) from build/add-push-drone-pipeleine-2 into main
continuous-integration/drone/push Build is passing
Reviewed-on: #7
2026-03-09 11:42:53 +00:00
Dennis Hundertmark a779a16acb ci: use netrc for manifest repo push
continuous-integration/drone/pr Build is passing
2026-03-09 12:40:01 +01:00
mnky f3ac85cfb4 Merge pull request 'ci: add temporary drone git auth debugging' (#6) from build/add-push-drone-pipeleine-2 into main
continuous-integration/drone/push Build is failing
Reviewed-on: #6
2026-03-09 11:09:27 +00:00
Dennis Hundertmark 4ecd29996e ci: add temporary drone git auth debugging
continuous-integration/drone/pr Build is passing
2026-03-09 12:04:13 +01:00
mnky 9095cdf4ed Merge pull request 'ci: use dedicated git secrets for manifest updates' (#5) from build/add-push-drone-pipeleine-2 into main
continuous-integration/drone/push Build is failing
Reviewed-on: #5
2026-03-09 10:35:38 +00:00
Dennis Hundertmark 305602644c ci: use dedicated git secrets for manifest updates
continuous-integration/drone/pr Build is passing
2026-03-09 11:33:46 +01:00
mnky a240dd4b22 Merge pull request 'ci: update authentication to use GITEA_TOKEN for secure Git operations' (#4) from build/add-push-drone-pipeleine-2 into main
continuous-integration/drone/push Build is failing
Reviewed-on: #4
2026-03-08 09:29:45 +00:00
Dennis Hundertmark 7a1dda51a4 ci: update authentication to use GITEA_TOKEN for secure Git operations
continuous-integration/drone/pr Build is passing
- Replace GITEA_PASSWORD with GITEA_TOKEN in environment variables
- Add validation checks for GITEA_USERNAME and GITEA_TOKEN
- Update git remote URL to include token for authentication
- Simplify push command to use configured origin remote

This change enhances security by using a token instead of a password for Git authentication in the CI pipeline.
2026-03-08 10:29:18 +01:00
mnky 001b43c74c Merge pull request 'feat: Add a new step to the deploy pipeline to update Kubernetes manifests with the latest image tag.' (#3) from build/add-push-drone-pipeleine into main
continuous-integration/drone/push Build is failing
Reviewed-on: #3
2026-03-08 09:11:14 +00:00
Dennis Hundertmark d622684a08 feat: Add a new step to the deploy pipeline to update Kubernetes manifests with the latest image tag.
continuous-integration/drone/pr Build is passing
2026-03-08 10:10:24 +01:00
mnky c4d4f73bd3 Merge pull request 'feat: add ngrx store implementation' (#2) from feat/add-ngrx into main
continuous-integration/drone/push Build is passing
Reviewed-on: #2
2026-03-08 08:53:34 +00:00
Dennis Hundertmark 9d13cc652a feat: Implement tasks feature using NGRX signals and remove the old counter store, alongside general project configuration and skill documentation updates.
continuous-integration/drone/pr Build is passing
2026-03-08 09:50:17 +01:00
Dennis Hundertmark 2184971175 feat: add Angular NgRx best practices documentation 2026-03-08 08:51:02 +01:00
Dennis Hundertmark 67dc823270 feat: add ngrx store implementation
continuous-integration/drone/pr Build is passing
2026-03-08 08:46:31 +01:00
mnky c84201e47b .drone.yml aktualisiert 2026-03-07 21:38:18 +00:00
mnky adf294fd85 .drone.yml aktualisiert
continuous-integration/drone/push Build was killed
2026-03-07 21:36:48 +00:00
mnky 4d1a13ae95 .drone.yml aktualisiert
continuous-integration/drone/push Build is passing
2026-03-07 21:35:59 +00:00
mnky f6e4571bdd .drone.yml aktualisiert
continuous-integration/drone/push Build is passing
2026-03-07 21:34:10 +00:00
mnky e0d2515606 .drone.yml aktualisiert
continuous-integration/drone/push Build is passing
2026-03-07 21:33:28 +00:00
mnky 1b019d8f4e .drone.yml aktualisiert
continuous-integration/drone/push Build was killed
2026-03-07 21:31:25 +00:00
mnky dbf410b8df .drone.yml aktualisiert
continuous-integration/drone/push Build encountered an error
2026-03-07 21:30:43 +00:00
mnky 1b2d02f7b2 .drone.yml aktualisiert
continuous-integration/drone/push Build encountered an error
2026-03-07 21:28:19 +00:00
mnky 8211c0cfe5 .drone.yml aktualisiert 2026-03-07 21:26:03 +00:00
mnky 87bcd7c97a .drone.yml aktualisiert
continuous-integration/drone/push Build encountered an error
2026-03-07 21:25:13 +00:00
mnky 828464511d .drone.yml aktualisiert
continuous-integration/drone/push Build encountered an error
2026-03-07 21:24:33 +00:00
mnky 0ae0674a44 .drone.yml aktualisiert
continuous-integration/drone/push Build encountered an error
2026-03-07 21:23:01 +00:00
mnky 6428b32f30 .drone.yml aktualisiert
continuous-integration/drone/push Build encountered an error
2026-03-07 21:21:16 +00:00
mnky be4b752e00 Merge pull request 'chore/adjust-readme' (#1) from chore/adjust-readme into main
continuous-integration/drone/push Build is passing
Reviewed-on: #1
2026-03-06 16:01:23 +00:00
74 changed files with 20305 additions and 9145 deletions
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-best-practices-ngrx
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-component
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-di
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-directives
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-forms
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-http
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-routing
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-signals
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-testing
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-tooling
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/frontend-design
@@ -0,0 +1,37 @@
# Angular Ngrx Best Practices
> Use with the core `angular-best-practices` skill.
---
## 1. NgRx State Management
**Impact: HIGH** (Global state)
### 1.1 Keep Reducers Pure
**Impact: HIGH** (Predictable state, testable, time-travel debugging)
Reducers must be pure functions: no side effects, no HTTP calls, no subscriptions. Only compute new state from action and current state. Move side effects to Effects.
**Example:**
```typescript
on(UsersActions.load, (state) => ({ ...state, loading: true }));
// HTTP call goes in UsersEffects
```
### 1.2 Use Feature Selectors
**Impact: MEDIUM** (Memoized selection, better performance)
Use `createFeatureSelector` and `createSelector` for memoized state selection. Selectors only recompute when their inputs change.
**Example:**
```typescript
const selectCounterState = createFeatureSelector<CounterState>('counter');
export const selectCount = createSelector(selectCounterState, (s) => s.count);
```
---
@@ -0,0 +1,51 @@
---
name: angular-best-practices-ngrx
description: >-
NgRx state management best practices for Angular. Covers pure reducers,
action groups, entity adapter, selectors, and signal-based selection.
Activates when working with @ngrx/store, @ngrx/effects, and @ngrx/entity.
Do not use for Akita, NGXS, or standalone signal-based state.
Install alongside angular-best-practices for full coverage.
license: MIT
metadata:
author: alfredoperez
version: '1.2.0'
tags: [angular, ngrx, state-management, redux]
globs:
- '**/*.ts'
- '**/*.reducer.ts'
- '**/*.effects.ts'
- '**/*.selectors.ts'
---
# Angular NgRx Best Practices
NgRx state management rules for global state with actions, reducers, effects, and selectors. Use with the core
[angular-best-practices](https://skills.sh/alfredoperez/angular-best-practices/angular-best-practices)
skill for comprehensive Angular coverage.
## Links
- [Core Skill: angular-best-practices](https://skills.sh/alfredoperez/angular-best-practices/angular-best-practices)
- [Browse All Skills](https://skills.sh/alfredoperez/angular-best-practices)
- [GitHub Repository](https://github.com/alfredoperez/angular-best-practices)
## When to Apply
- Adding or modifying NgRx stores, reducers, or effects
- Writing selectors for state selection in components
- Managing collections with `@ngrx/entity`
## Rules
| Rule | Impact | Description |
| --------------------- | ------ | --------------------------------------------------------- |
| Keep Reducers Pure | HIGH | No side effects in reducers; move HTTP calls to Effects |
| Use Feature Selectors | MEDIUM | Memoized selectors that recompute only when inputs change |
## Install
Install from [skills.sh/alfredoperez/angular-best-practices](https://skills.sh/alfredoperez/angular-best-practices):
- Core skill: [angular-best-practices](https://skills.sh/alfredoperez/angular-best-practices/angular-best-practices)
- This add-on: [angular-best-practices-ngrx](https://skills.sh/alfredoperez/angular-best-practices/angular-best-practices-ngrx)
+302
View File
@@ -0,0 +1,302 @@
---
name: angular-component
description: Create modern Angular standalone components following v20+ best practices. Use for building UI components with signal-based inputs/outputs, OnPush change detection, host bindings, content projection, and lifecycle hooks. Triggers on component creation, refactoring class-based inputs to signals, adding host bindings, or implementing accessible interactive components.
---
# Angular Component
Create standalone components for Angular v20+. Components are standalone by default—do NOT set `standalone: true`.
## Component Structure
```typescript
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'class': 'user-card',
'[class.active]': 'isActive()',
'(click)': 'handleClick()',
},
template: `
<img
[src]="avatarUrl()"
[alt]="name() + ' avatar'" />
<h2>{{ name() }}</h2>
@if (showEmail()) {
<p>{{ email() }}</p>
}
`,
styles: `
:host {
display: block;
}
:host.active {
border: 2px solid blue;
}
`,
})
export class UserCard {
// Required input
name = input.required<string>();
// Optional input with default
email = input<string>('');
showEmail = input(false);
// Input with transform
isActive = input(false, { transform: booleanAttribute });
// Computed from inputs
avatarUrl = computed(() => `https://api.example.com/avatar/${this.name()}`);
// Output
selected = output<string>();
handleClick() {
this.selected.emit(this.name());
}
}
```
## Signal Inputs
```typescript
// Required - must be provided by parent
name = input.required<string>();
// Optional with default value
count = input(0);
// Optional without default (undefined allowed)
label = input<string>();
// With alias for template binding
size = input('medium', { alias: 'buttonSize' });
// With transform function
disabled = input(false, { transform: booleanAttribute });
value = input(0, { transform: numberAttribute });
```
## Signal Outputs
```typescript
import { output, outputFromObservable } from '@angular/core';
// Basic output
clicked = output<void>();
selected = output<Item>();
// With alias
valueChange = output<number>({ alias: 'change' });
// From Observable (for RxJS interop)
scroll$ = new Subject<number>();
scrolled = outputFromObservable(this.scroll$);
// Emit values
this.clicked.emit();
this.selected.emit(item);
```
## Host Bindings
Use the `host` object in `@Component`—do NOT use `@HostBinding` or `@HostListener` decorators.
```typescript
@Component({
selector: 'app-button',
host: {
// Static attributes
'role': 'button',
// Dynamic class bindings
'[class.primary]': 'variant() === "primary"',
'[class.disabled]': 'disabled()',
// Dynamic style bindings
'[style.--btn-color]': 'color()',
// Attribute bindings
'[attr.aria-disabled]': 'disabled()',
'[attr.tabindex]': 'disabled() ? -1 : 0',
// Event listeners
'(click)': 'onClick($event)',
'(keydown.enter)': 'onClick($event)',
'(keydown.space)': 'onClick($event)',
},
template: `<ng-content />`,
})
export class Button {
variant = input<'primary' | 'secondary'>('primary');
disabled = input(false, { transform: booleanAttribute });
color = input('#007bff');
clicked = output<void>();
onClick(event: Event) {
if (!this.disabled()) {
this.clicked.emit();
}
}
}
```
## Content Projection
```typescript
@Component({
selector: 'app-card',
template: `
<header>
<ng-content select="[card-header]" />
</header>
<main>
<ng-content />
</main>
<footer>
<ng-content select="[card-footer]" />
</footer>
`,
})
export class Card {}
// Usage:
// <app-card>
// <h2 card-header>Title</h2>
// <p>Main content</p>
// <button card-footer>Action</button>
// </app-card>
```
## Lifecycle Hooks
```typescript
import { OnDestroy, OnInit, afterNextRender, afterRender } from '@angular/core';
export class My implements OnInit, OnDestroy {
constructor() {
// For DOM manipulation after render (SSR-safe)
afterNextRender(() => {
// Runs once after first render
});
afterRender(() => {
// Runs after every render
});
}
ngOnInit() {
/* Component initialized */
}
ngOnDestroy() {
/* Cleanup */
}
}
```
## Accessibility Requirements
Components MUST:
- Pass AXE accessibility checks
- Meet WCAG AA standards
- Include proper ARIA attributes for interactive elements
- Support keyboard navigation
- Maintain visible focus indicators
```typescript
@Component({
selector: 'app-toggle',
host: {
'role': 'switch',
'[attr.aria-checked]': 'checked()',
'[attr.aria-label]': 'label()',
'tabindex': '0',
'(click)': 'toggle()',
'(keydown.enter)': 'toggle()',
'(keydown.space)': 'toggle(); $event.preventDefault()',
},
template: `<span class="toggle-track"><span class="toggle-thumb"></span></span>`,
})
export class Toggle {
label = input.required<string>();
checked = input(false, { transform: booleanAttribute });
checkedChange = output<boolean>();
toggle() {
this.checkedChange.emit(!this.checked());
}
}
```
## Template Syntax
Use native control flow—do NOT use `*ngIf`, `*ngFor`, `*ngSwitch`.
```html
<!-- Conditionals -->
@if (isLoading()) {
<app-spinner />
} @else if (error()) {
<app-error [message]="error()" />
} @else {
<app-content [data]="data()" />
}
<!-- Loops -->
@for (item of items(); track item.id) {
<app-item [item]="item" />
} @empty {
<p>No items found</p>
}
<!-- Switch -->
@switch (status()) { @case ('pending') { <span>Pending</span> } @case ('active') { <span>Active</span> } @default { <span>Unknown</span> } }
```
## Class and Style Bindings
Do NOT use `ngClass` or `ngStyle`. Use direct bindings:
```html
<!-- Class bindings -->
<div [class.active]="isActive()">Single class</div>
<div [class]="classString()">Class string</div>
<!-- Style bindings -->
<div [style.color]="textColor()">Styled text</div>
<div [style.width.px]="width()">With unit</div>
```
## Images
Use `NgOptimizedImage` for static images:
```typescript
import { NgOptimizedImage } from '@angular/common';
@Component({
imports: [NgOptimizedImage],
template: `
<img
ngSrc="/assets/hero.jpg"
width="800"
height="600"
priority />
<img
width="200"
height="200"
[ngSrc]="imageUrl()" />
`,
})
export class Hero {
imageUrl = input.required<string>();
}
```
For detailed patterns, see [references/component-patterns.md](references/component-patterns.md).
@@ -0,0 +1,360 @@
# Angular Component Patterns
## Table of Contents
- [Model Inputs (Two-Way Binding)](#model-inputs-two-way-binding)
- [View Queries](#view-queries)
- [Content Queries](#content-queries)
- [Dependency Injection in Components](#dependency-injection-in-components)
- [Component Communication Patterns](#component-communication-patterns)
- [Dynamic Components](#dynamic-components)
## Model Inputs (Two-Way Binding)
For two-way binding with `[(value)]` syntax:
```typescript
import { Component, model } from '@angular/core';
@Component({
selector: 'app-slider',
host: {
'(input)': 'onInput($event)',
},
template: `
<input
type="range"
[value]="value()"
[min]="min()"
[max]="max()" />
<span>{{ value() }}</span>
`,
})
export class Slider {
// Model creates both input and output
value = model(0);
min = input(0);
max = input(100);
onInput(event: Event) {
const target = event.target as HTMLInputElement;
this.value.set(Number(target.value));
}
}
// Usage: <app-slider [(value)]="sliderValue" />
```
Required model:
```typescript
value = model.required<number>();
```
## View Queries
Query elements and components in the template:
```typescript
import { Component, viewChild, viewChildren, ElementRef } from '@angular/core';
@Component({
selector: 'app-gallery',
template: `
<div
#container
class="gallery">
@for (image of images(); track image.id) {
<app-image-card [image]="image" />
}
</div>
`,
})
export class Gallery {
images = input.required<Image[]>();
// Query single element
container = viewChild.required<ElementRef<HTMLDivElement>>('container');
// Query single component (optional)
firstCard = viewChild(ImageCard);
// Query all matching components
allCards = viewChildren(ImageCard);
}
```
## Content Queries
Query projected content:
```typescript
import { Component, contentChild, contentChildren, effect, signal } from '@angular/core';
@Component({
selector: 'app-tabs',
template: `
<div class="tab-headers">
@for (tab of tabs(); track tab.label()) {
<button
[class.active]="tab === activeTab()"
(click)="selectTab(tab)">
{{ tab.label() }}
</button>
}
</div>
<div class="tab-content">
<ng-content />
</div>
`,
})
export class Tabs {
// Query all projected Tab children
tabs = contentChildren(Tab);
// Query single projected element
header = contentChild('tabHeader');
activeTab = signal<Tab | undefined>(undefined);
constructor() {
// Set first tab as active when tabs are available
effect(() => {
const firstTab = this.tabs()[0];
if (firstTab && !this.activeTab()) {
this.activeTab.set(firstTab);
}
});
}
selectTab(tab: Tab) {
this.activeTab.set(tab);
}
}
@Component({
selector: 'app-tab',
template: `<ng-content />`,
host: {
'[class.active]': 'isActive()',
'[style.display]': 'isActive() ? "block" : "none"',
},
})
export class Tab {
label = input.required<string>();
isActive = input(false);
}
```
## Dependency Injection in Components
Use `inject()` function instead of constructor injection:
```typescript
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-dashboard',
template: `...`,
})
export class Dashboard {
private router = inject(Router);
private userService = inject(User);
private config = inject(APP_CONFIG);
// Optional injection
private analytics = inject(Analytics, { optional: true });
// Self-only injection
private localService = inject(Local, { self: true });
navigateToProfile() {
this.router.navigate(['/profile']);
}
}
```
## Component Communication Patterns
### Parent to Child (Inputs)
```typescript
// Parent
@Component({
template: `<app-child
[data]="parentData()"
[config]="config" />`,
})
export class Parent {
parentData = signal({ name: 'Test' });
config = { theme: 'dark' };
}
// Child
@Component({ selector: 'app-child' })
export class Child {
data = input.required<Data>();
config = input<Config>();
}
```
### Child to Parent (Outputs)
```typescript
// Child
@Component({
selector: 'app-child',
template: `<button (click)="save()">Save</button>`,
})
export class Child {
saved = output<Data>();
save() {
this.saved.emit({ id: 1, name: 'Item' });
}
}
// Parent
@Component({
template: `<app-child (saved)="onSaved($event)" />`,
})
export class Parent {
onSaved(data: Data) {
console.log('Saved:', data);
}
}
```
### Shared Service Pattern
```typescript
// Shared state service
@Injectable({ providedIn: 'root' })
export class Cart {
private items = signal<CartItem[]>([]);
readonly items$ = this.items.asReadonly();
readonly total = computed(() => this.items().reduce((sum, item) => sum + item.price, 0));
addItem(item: CartItem) {
this.items.update((items) => [...items, item]);
}
removeItem(id: string) {
this.items.update((items) => items.filter((i) => i.id !== id));
}
}
// Component A
@Component({ template: `<button (click)="add()">Add</button>` })
export class Product {
private cart = inject(Cart);
product = input.required<Product>();
add() {
this.cart.addItem({ ...this.product(), quantity: 1 });
}
}
// Component B
@Component({ template: `<span>Total: {{ cart.total() }}</span>` })
export class CartSummary {
cart = inject(Cart);
}
```
## Dynamic Components
Using `@defer` for lazy loading:
```typescript
@Component({
template: `
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder {
<div class="chart-placeholder">Loading chart...</div>
} @loading (minimum 500ms) {
<app-spinner />
} @error {
<p>Failed to load chart</p>
}
`,
})
export class Dashboard {
chartData = input.required<ChartData>();
}
```
Defer triggers:
- `on viewport` - When element enters viewport
- `on idle` - When browser is idle
- `on interaction` - On user interaction (click, focus)
- `on hover` - On mouse hover
- `on immediate` - Immediately after non-deferred content
- `on timer(500ms)` - After specified delay
- `when condition` - When expression becomes true
```typescript
@Component({
template: `
@defer (on interaction; prefetch on idle) {
<app-comments [postId]="postId()" />
} @placeholder {
<button>Load Comments</button>
}
`,
})
export class Post {
postId = input.required<string>();
}
```
## Attribute Directives on Components
```typescript
@Directive({
selector: '[appHighlight]',
host: {
'[style.backgroundColor]': 'color()',
},
})
export class Highlight {
color = input('yellow', { alias: 'appHighlight' });
}
// Usage on component
@Component({
imports: [Highlight],
template: `<app-card appHighlight="lightblue" />`,
})
export class Page {}
```
## Error Boundaries
```typescript
@Component({
selector: 'app-error-boundary',
template: `
@if (hasError()) {
<div class="error">
<h3>Something went wrong</h3>
<button (click)="retry()">Retry</button>
</div>
} @else {
<ng-content />
}
`,
})
export class ErrorBoundary {
hasError = signal(false);
private errorHandler = inject(ErrorHandler);
retry() {
this.hasError.set(false);
}
}
```
+373
View File
@@ -0,0 +1,373 @@
---
name: angular-di
description: Implement dependency injection in Angular v20+ using inject(), injection tokens, and provider configuration. Use for service architecture, providing dependencies at different levels, creating injectable tokens, and managing singleton vs scoped services. Triggers on service creation, configuring providers, using injection tokens, or understanding DI hierarchy.
---
# Angular Dependency Injection
Configure and use dependency injection in Angular v20+ with `inject()` and providers.
## Basic Injection
### Using inject()
Prefer `inject()` over constructor injection:
```typescript
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { User } from './user.service';
@Component({
selector: 'app-user-list',
template: `...`,
})
export class UserList {
// Inject dependencies
private http = inject(HttpClient);
private userService = inject(User);
// Can use immediately
users = this.userService.getUsers();
}
```
### Injectable Services
```typescript
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root', // Singleton at root level
})
export class User {
private http = inject(HttpClient);
private users = signal<User[]>([]);
readonly users$ = this.users.asReadonly();
async loadUsers() {
const users = await firstValueFrom(this.http.get<User[]>('/api/users'));
this.users.set(users);
}
}
```
## Provider Scopes
### Root Level (Singleton)
```typescript
// Recommended: providedIn
@Injectable({
providedIn: 'root',
})
export class Auth {}
// Alternative: in app.config.ts
export const appConfig: ApplicationConfig = {
providers: [Auth],
};
```
### Component Level (Instance per Component)
```typescript
@Component({
selector: 'app-editor',
providers: [EditorState], // New instance for each component
template: `...`,
})
export class Editor {
private editorState = inject(EditorState);
}
```
### Route Level
```typescript
export const routes: Routes = [
{
path: 'admin',
providers: [Admin], // Shared within this route tree
children: [
{ path: '', component: AdminDashboard },
{ path: 'users', component: AdminUsers },
],
},
];
```
## Injection Tokens
### Creating Tokens
```typescript
import { InjectionToken } from '@angular/core';
// Simple value token
export const API_URL = new InjectionToken<string>('API_URL');
// Object token
export interface AppConfig {
apiUrl: string;
features: {
darkMode: boolean;
analytics: boolean;
};
}
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
// Token with factory (self-providing)
export const WINDOW = new InjectionToken<Window>('Window', {
providedIn: 'root',
factory: () => window,
});
export const LOCAL_STORAGE = new InjectionToken<Storage>('LocalStorage', {
providedIn: 'root',
factory: () => localStorage,
});
```
### Providing Token Values
```typescript
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
{ provide: API_URL, useValue: 'https://api.example.com' },
{
provide: APP_CONFIG,
useValue: {
apiUrl: 'https://api.example.com',
features: { darkMode: true, analytics: true },
},
},
],
};
```
### Injecting Tokens
```typescript
@Injectable({ providedIn: 'root' })
export class Api {
private apiUrl = inject(API_URL);
private config = inject(APP_CONFIG);
private window = inject(WINDOW);
getBaseUrl(): string {
return this.apiUrl;
}
}
```
## Provider Types
### useClass
```typescript
// Provide implementation
{ provide: Logger, useClass: ConsoleLogger }
// Conditional implementation
{
provide: Logger,
useClass: environment.production
? ProductionLogger
: ConsoleLogger,
}
```
### useValue
```typescript
// Static values
{ provide: API_URL, useValue: 'https://api.example.com' }
// Configuration objects
{ provide: APP_CONFIG, useValue: { theme: 'dark', language: 'en' } }
```
### useFactory
```typescript
// Factory with dependencies
{
provide: User,
useFactory: (http: HttpClient, config: AppConfig) => {
return new User(http, config.apiUrl);
},
deps: [HttpClient, APP_CONFIG],
}
// Async factory (not recommended - use provideAppInitializer)
{
provide: CONFIG,
useFactory: () => fetch('/config.json').then(r => r.json()),
}
```
### useExisting
```typescript
// Alias to existing provider
{ provide: AbstractLogger, useExisting: ConsoleLogger }
// Multiple tokens pointing to same instance
providers: [
ConsoleLogger,
{ provide: Logger, useExisting: ConsoleLogger },
{ provide: ErrorLogger, useExisting: ConsoleLogger },
]
```
## Injection Options
### Optional Injection
```typescript
@Component({...})
export class My {
// Returns null if not provided
private analytics = inject(Analytics, { optional: true });
trackEvent(name: string) {
this.analytics?.track(name);
}
}
```
### Self, SkipSelf, Host
```typescript
@Component({
providers: [Local],
})
export class Parent {
// Only look in this component's injector
private local = inject(Local, { self: true });
}
@Component({...})
export class Child {
// Skip this component, look in parent
private parentService = inject(ParentSvc, { skipSelf: true });
// Only look up to host component
private hostService = inject(Host, { host: true });
}
```
## Multi Providers
Collect multiple values for same token:
```typescript
// Token for multiple validators
export const VALIDATORS = new InjectionToken<Validator[]>('Validators');
// Provide multiple values
providers: [
{ provide: VALIDATORS, useClass: RequiredValidator, multi: true },
{ provide: VALIDATORS, useClass: EmailValidator, multi: true },
{ provide: VALIDATORS, useClass: MinLengthValidator, multi: true },
];
// Inject as array
@Injectable()
export class Validation {
private validators = inject(VALIDATORS); // Validator[]
validate(value: string): ValidationError[] {
return this.validators.map((v) => v.validate(value)).filter(Boolean);
}
}
```
### HTTP Interceptors (Multi Provider)
```typescript
// Interceptors use multi providers internally
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([authInterceptor, loggingInterceptor, errorInterceptor]))],
};
```
## App Initializers
Run async code before app starts using `provideAppInitializer`:
```typescript
import { provideAppInitializer, inject } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
Config,
provideAppInitializer(() => {
const configService = inject(Config);
return configService.loadConfig();
}),
],
};
```
### Multiple Initializers
```typescript
providers: [
provideAppInitializer(() => {
const config = inject(Config);
return config.load();
}),
provideAppInitializer(() => {
const auth = inject(Auth);
return auth.checkSession();
}),
];
```
## Environment Injector
Create injectors programmatically:
```typescript
import { createEnvironmentInjector, EnvironmentInjector, inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Plugin {
private parentInjector = inject(EnvironmentInjector);
loadPlugin(providers: Provider[]): EnvironmentInjector {
return createEnvironmentInjector(providers, this.parentInjector);
}
}
```
## runInInjectionContext
Run code with injection context:
```typescript
import { runInInjectionContext, EnvironmentInjector, inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Utility {
private injector = inject(EnvironmentInjector);
executeWithDI<T>(fn: () => T): T {
return runInInjectionContext(this.injector, fn);
}
}
// Usage
utilityService.executeWithDI(() => {
const http = inject(HttpClient);
// Use http...
});
```
For advanced patterns, see [references/di-patterns.md](references/di-patterns.md).
@@ -0,0 +1,517 @@
# Angular Dependency Injection Patterns
## Table of Contents
- [Service Patterns](#service-patterns)
- [Abstract Classes as Tokens](#abstract-classes-as-tokens)
- [Hierarchical Injection](#hierarchical-injection)
- [Dynamic Providers](#dynamic-providers)
- [Testing with DI](#testing-with-di)
- [DestroyRef and Cleanup](#destroyref-and-cleanup)
## Service Patterns
### Facade Service
Combine multiple services into a single API:
```typescript
@Injectable({ providedIn: 'root' })
export class ShopFacade {
private productService = inject(Product);
private cartService = inject(Cart);
private orderService = inject(Order);
// Expose combined state
readonly products = this.productService.products;
readonly cart = this.cartService.items;
readonly cartTotal = this.cartService.total;
// Unified actions
addToCart(productId: string, quantity: number) {
const product = this.productService.getById(productId);
if (product) {
this.cartService.add(product, quantity);
}
}
async checkout() {
const items = this.cartService.items();
const order = await this.orderService.create(items);
this.cartService.clear();
return order;
}
}
```
### State Service Pattern
```typescript
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
}
@Injectable({ providedIn: 'root' })
export class UserState {
private state = signal<UserState>({
user: null,
loading: false,
error: null,
});
// Selectors
readonly user = computed(() => this.state().user);
readonly loading = computed(() => this.state().loading);
readonly error = computed(() => this.state().error);
readonly isAuthenticated = computed(() => this.state().user !== null);
// Actions
setUser(user: User) {
this.state.update((s) => ({ ...s, user, loading: false, error: null }));
}
setLoading() {
this.state.update((s) => ({ ...s, loading: true, error: null }));
}
setError(error: string) {
this.state.update((s) => ({ ...s, loading: false, error }));
}
clear() {
this.state.set({ user: null, loading: false, error: null });
}
}
```
### Repository Pattern
```typescript
// Generic repository interface
export abstract class Repository<T extends { id: string }> {
abstract getAll(): Promise<T[]>;
abstract getById(id: string): Promise<T | null>;
abstract create(item: Omit<T, 'id'>): Promise<T>;
abstract update(id: string, item: Partial<T>): Promise<T>;
abstract delete(id: string): Promise<void>;
}
// HTTP implementation
@Injectable()
export class HttpUserRepo extends Repository<User> {
private http = inject(HttpClient);
private apiUrl = inject(API_URL);
async getAll(): Promise<User[]> {
return firstValueFrom(this.http.get<User[]>(`${this.apiUrl}/users`));
}
async getById(id: string): Promise<User | null> {
return firstValueFrom(
this.http.get<User>(`${this.apiUrl}/users/${id}`).pipe(
catchError(() => of(null))
)
);
}
async create(user: Omit<User, 'id'>): Promise<User> {
return firstValueFrom(this.http.post<User>(`${this.apiUrl}/users`, user));
}
async update(id: string, user: Partial<User>): Promise<User> {
return firstValueFrom(this.http.patch<User>(`${this.apiUrl}/users/${id}`, user));
}
async delete(id: string): Promise<void> {
await firstValueFrom(this.http.delete(`${this.apiUrl}/users/${id}`));
}
}
// Provide implementation
{ provide: Repository, useClass: HttpUserRepo }
```
## Abstract Classes as Tokens
Use abstract classes for better type safety:
```typescript
// Abstract service definition
export abstract class Logger {
abstract log(message: string): void;
abstract error(message: string, error?: Error): void;
abstract warn(message: string): void;
}
// Console implementation
@Injectable()
export class ConsoleLog extends Logger {
log(message: string) {
console.log(`[LOG] ${message}`);
}
error(message: string, error?: Error) {
console.error(`[ERROR] ${message}`, error);
}
warn(message: string) {
console.warn(`[WARN] ${message}`);
}
}
// Remote implementation
@Injectable()
export class RemoteLog extends Logger {
private http = inject(HttpClient);
log(message: string) {
this.send('log', message);
}
error(message: string, error?: Error) {
this.send('error', message, error);
}
warn(message: string) {
this.send('warn', message);
}
private send(level: string, message: string, error?: Error) {
this.http.post('/api/logs', { level, message, error: error?.message }).subscribe();
}
}
// Provide based on environment
{
provide: Logger,
useClass: environment.production ? RemoteLog : ConsoleLog,
}
// Inject using abstract class
@Injectable({ providedIn: 'root' })
export class User {
private logger = inject(Logger);
createUser(user: UserData) {
this.logger.log(`Creating user: ${user.email}`);
// ...
}
}
```
## Hierarchical Injection
### Component Tree Injection
```typescript
// Parent provides service
@Component({
selector: 'app-form-container',
providers: [FormState],
template: `
<app-form-header />
<app-form-body />
<app-form-footer />
`,
})
export class FormContainer {
private formState = inject(FormState);
}
// Children share same instance
@Component({
selector: 'app-form-body',
template: `...`,
})
export class FormBody {
// Gets same instance as parent
private formState = inject(FormState);
}
// Grandchildren also share
@Component({
selector: 'app-form-field',
template: `...`,
})
export class FormField {
// Gets same instance from ancestor
private formState = inject(FormState);
}
```
### viewProviders vs providers
```typescript
@Component({
selector: 'app-tabs',
// providers: Available to component AND content children
providers: [TabsSvc],
// viewProviders: Available to component AND view children only
// NOT available to content children (<ng-content>)
viewProviders: [InternalTabs],
template: `
<div class="tabs">
<ng-content />
<!-- Content children can't access viewProviders -->
</div>
`,
})
export class Tabs {}
```
## Dynamic Providers
### Feature Flags
```typescript
export const FEATURE_FLAGS = new InjectionToken<FeatureFlags>('FeatureFlags');
interface FeatureFlags {
newDashboard: boolean;
betaFeatures: boolean;
experimentalApi: boolean;
}
// Load from API
{
provide: FEATURE_FLAGS,
useFactory: async () => {
const response = await fetch('/api/features');
return response.json();
},
}
// Use in components
@Component({...})
export class Dashboard {
private features = inject(FEATURE_FLAGS);
showNewDashboard = this.features.newDashboard;
}
```
### Platform-Specific Services
```typescript
export abstract class Storage {
abstract get(key: string): string | null;
abstract set(key: string, value: string): void;
abstract remove(key: string): void;
}
@Injectable()
export class BrowserStorage extends Storage {
get(key: string) { return localStorage.getItem(key); }
set(key: string, value: string) { localStorage.setItem(key, value); }
remove(key: string) { localStorage.removeItem(key); }
}
@Injectable()
export class ServerStorage extends Storage {
private store = new Map<string, string>();
get(key: string) { return this.store.get(key) ?? null; }
set(key: string, value: string) { this.store.set(key, value); }
remove(key: string) { this.store.delete(key); }
}
// Provide based on platform
import { PLATFORM_ID, isPlatformBrowser } from '@angular/common';
{
provide: Storage,
useFactory: (platformId: object) => {
return isPlatformBrowser(platformId)
? new BrowserStorage()
: new ServerStorage();
},
deps: [PLATFORM_ID],
}
```
## Testing with DI
### Mocking Services
```typescript
describe('UserCmpt', () => {
let userServiceSpy: jasmine.SpyObj<User>;
beforeEach(async () => {
userServiceSpy = jasmine.createSpyObj('User', ['getUser', 'updateUser']);
userServiceSpy.getUser.and.returnValue(of({ id: '1', name: 'Test' }));
await TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [{ provide: User, useValue: userServiceSpy }],
}).compileComponents();
});
it('should load user', () => {
const fixture = TestBed.createComponent(UserCmpt);
fixture.detectChanges();
expect(userServiceSpy.getUser).toHaveBeenCalled();
});
});
```
### Overriding Providers
```typescript
describe('with different config', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
})
.overrideProvider(APP_CONFIG, {
useValue: { apiUrl: 'http://test-api.com' },
})
.compileComponents();
});
});
```
### Testing Injection Tokens
```typescript
describe('API_URL token', () => {
it('should provide correct URL', () => {
TestBed.configureTestingModule({
providers: [{ provide: API_URL, useValue: 'https://api.test.com' }],
});
const apiUrl = TestBed.inject(API_URL);
expect(apiUrl).toBe('https://api.test.com');
});
});
```
## DestroyRef and Cleanup
### Automatic Cleanup
```typescript
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({...})
export class Data {
private destroyRef = inject(DestroyRef);
private dataService = inject(DataSvc);
constructor() {
// Auto-unsubscribe when component destroys
this.dataService.data$
.pipe(takeUntilDestroyed())
.subscribe(data => {
console.log(data);
});
}
// Or use DestroyRef directly
ngOnInit() {
const subscription = this.dataService.updates$.subscribe();
this.destroyRef.onDestroy(() => {
subscription.unsubscribe();
console.log('Cleaned up!');
});
}
}
```
### In Services
```typescript
@Injectable()
export class WebSocket {
private destroyRef = inject(DestroyRef);
private socket: WebSocket | null = null;
constructor() {
this.destroyRef.onDestroy(() => {
this.socket?.close();
});
}
connect(url: string) {
this.socket = new WebSocket(url);
}
}
```
### takeUntilDestroyed Outside Constructor
```typescript
@Component({...})
export class My {
private destroyRef = inject(DestroyRef);
loadData() {
// Pass destroyRef when using outside constructor
this.http.get('/api/data')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
}
```
## Injection Context Utilities
### assertInInjectionContext
```typescript
import { assertInInjectionContext, inject } from '@angular/core';
export function injectLogger(): Logger {
assertInInjectionContext(injectLogger);
return inject(Logger);
}
// Usage - must be called in injection context
@Component({...})
export class My2 {
private logger = injectLogger(); // OK
someMethod() {
// injectLogger(); // ERROR - not in injection context
}
}
```
### Custom inject Functions
```typescript
// Create reusable injection utilities
export function injectRouteParam(param: string): Signal<string | null> {
assertInInjectionContext(injectRouteParam);
const route = inject(ActivatedRoute);
return toSignal(
route.paramMap.pipe(map(params => params.get(param))),
{ initialValue: null }
);
}
export function injectQueryParam(param: string): Signal<string | null> {
assertInInjectionContext(injectQueryParam);
const route = inject(ActivatedRoute);
return toSignal(
route.queryParamMap.pipe(map(params => params.get(param))),
{ initialValue: null }
);
}
// Usage
@Component({...})
export class UserCmpt {
userId = injectRouteParam('id');
tab = injectQueryParam('tab');
}
```
+443
View File
@@ -0,0 +1,443 @@
---
name: angular-directives
description: Create custom directives in Angular v20+ for DOM manipulation and behavior extension. Use for attribute directives that modify element behavior/appearance, structural directives for portals/overlays, and host directives for composition. Triggers on creating reusable DOM behaviors, extending element functionality, or composing behaviors across components. Note - use native @if/@for/@switch for control flow, not custom structural directives.
---
# Angular Directives
Create custom directives for reusable DOM manipulation and behavior in Angular v20+.
## Attribute Directives
Modify the appearance or behavior of an element:
```typescript
import { Directive, input, effect, inject, ElementRef } from '@angular/core';
@Directive({
selector: '[appHighlight]',
})
export class Highlight {
private el = inject(ElementRef<HTMLElement>);
// Input with alias matching selector
color = input('yellow', { alias: 'appHighlight' });
constructor() {
effect(() => {
this.el.nativeElement.style.backgroundColor = this.color();
});
}
}
// Usage: <p appHighlight="lightblue">Highlighted text</p>
// Usage: <p appHighlight>Default yellow highlight</p>
```
### Using host Property
Prefer `host` over `@HostBinding`/`@HostListener`:
```typescript
@Directive({
selector: '[appTooltip]',
host: {
'(mouseenter)': 'show()',
'(mouseleave)': 'hide()',
'[attr.aria-describedby]': 'tooltipId',
},
})
export class Tooltip {
text = input.required<string>({ alias: 'appTooltip' });
position = input<'top' | 'bottom' | 'left' | 'right'>('top');
tooltipId = `tooltip-${crypto.randomUUID()}`;
private tooltipEl: HTMLElement | null = null;
private el = inject(ElementRef<HTMLElement>);
show() {
this.tooltipEl = document.createElement('div');
this.tooltipEl.id = this.tooltipId;
this.tooltipEl.className = `tooltip tooltip-${this.position()}`;
this.tooltipEl.textContent = this.text();
this.tooltipEl.setAttribute('role', 'tooltip');
document.body.appendChild(this.tooltipEl);
this.positionTooltip();
}
hide() {
this.tooltipEl?.remove();
this.tooltipEl = null;
}
private positionTooltip() {
// Position logic based on this.position() and this.el
}
}
// Usage: <button appTooltip="Click to save" position="bottom">Save</button>
```
### Class and Style Manipulation
```typescript
@Directive({
selector: '[appButton]',
host: {
'class': 'btn',
'[class.btn-primary]': 'variant() === "primary"',
'[class.btn-secondary]': 'variant() === "secondary"',
'[class.btn-sm]': 'size() === "small"',
'[class.btn-lg]': 'size() === "large"',
'[class.disabled]': 'disabled()',
'[attr.disabled]': 'disabled() || null',
},
})
export class Button {
variant = input<'primary' | 'secondary'>('primary');
size = input<'small' | 'medium' | 'large'>('medium');
disabled = input(false, { transform: booleanAttribute });
}
// Usage: <button appButton variant="primary" size="large">Click</button>
```
### Event Handling
```typescript
@Directive({
selector: '[appClickOutside]',
host: {
'(document:click)': 'onDocumentClick($event)',
},
})
export class ClickOutside {
private el = inject(ElementRef<HTMLElement>);
clickOutside = output<void>();
onDocumentClick(event: MouseEvent) {
if (!this.el.nativeElement.contains(event.target as Node)) {
this.clickOutside.emit();
}
}
}
// Usage: <div appClickOutside (clickOutside)="closeMenu()">...</div>
```
### Keyboard Shortcuts
```typescript
@Directive({
selector: '[appShortcut]',
host: {
'(document:keydown)': 'onKeydown($event)',
},
})
export class Shortcut {
key = input.required<string>({ alias: 'appShortcut' });
ctrl = input(false, { transform: booleanAttribute });
shift = input(false, { transform: booleanAttribute });
alt = input(false, { transform: booleanAttribute });
triggered = output<KeyboardEvent>();
onKeydown(event: KeyboardEvent) {
const keyMatch = event.key.toLowerCase() === this.key().toLowerCase();
const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey;
const altMatch = this.alt() ? event.altKey : !event.altKey;
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
event.preventDefault();
this.triggered.emit(event);
}
}
}
// Usage: <button appShortcut="s" ctrl (triggered)="save()">Save (Ctrl+S)</button>
```
## Structural Directives
Use structural directives for DOM manipulation beyond control flow (portals, overlays, dynamic insertion points). For conditionals and loops, use native `@if`, `@for`, `@switch`.
### Portal Directive
Render content in a different DOM location:
```typescript
import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core';
@Directive({
selector: '[appPortal]',
})
export class Portal implements OnInit, OnDestroy {
private templateRef = inject(TemplateRef<any>);
private viewContainerRef = inject(ViewContainerRef);
private viewRef: EmbeddedViewRef<any> | null = null;
// Target container selector or element
target = input<string | HTMLElement>('body', { alias: 'appPortal' });
ngOnInit() {
const container = this.getContainer();
if (container) {
this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
this.viewRef.rootNodes.forEach((node) => container.appendChild(node));
}
}
ngOnDestroy() {
this.viewRef?.destroy();
}
private getContainer(): HTMLElement | null {
const target = this.target();
if (typeof target === 'string') {
return document.querySelector(target);
}
return target;
}
}
// Usage: Render modal at body level
// <div *appPortal="'body'">
// <div class="modal">Modal content</div>
// </div>
```
### Lazy Render Directive
Defer rendering until condition is met (one-time):
```typescript
@Directive({
selector: '[appLazyRender]',
})
export class LazyRender {
private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef);
private rendered = false;
condition = input.required<boolean>({ alias: 'appLazyRender' });
constructor() {
effect(() => {
// Only render once when condition becomes true
if (this.condition() && !this.rendered) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.rendered = true;
}
});
}
}
// Usage: Render heavy component only when tab is first activated
// <div *appLazyRender="activeTab() === 'reports'">
// <app-heavy-reports />
// </div>
```
### Template Outlet with Context
```typescript
interface TemplateContext<T> {
$implicit: T;
item: T;
index: number;
}
@Directive({
selector: '[appTemplateOutlet]',
})
export class TemplateOutlet<T> {
private viewContainer = inject(ViewContainerRef);
private currentView: EmbeddedViewRef<TemplateContext<T>> | null = null;
template = input.required<TemplateRef<TemplateContext<T>>>({
alias: 'appTemplateOutlet',
});
context = input.required<T>({ alias: 'appTemplateOutletContext' });
index = input(0, { alias: 'appTemplateOutletIndex' });
constructor() {
effect(() => {
const template = this.template();
const context = this.context();
const index = this.index();
if (this.currentView) {
this.currentView.context.$implicit = context;
this.currentView.context.item = context;
this.currentView.context.index = index;
this.currentView.markForCheck();
} else {
this.currentView = this.viewContainer.createEmbeddedView(template, {
$implicit: context,
item: context,
index,
});
}
});
}
}
// Usage: Custom list with template
// <ng-template #itemTemplate let-item let-i="index">
// <div>{{ i }}: {{ item.name }}</div>
// </ng-template>
// <ng-container
// *appTemplateOutlet="itemTemplate; context: item; index: i"
// />
```
## Host Directives
Compose directives on components or other directives:
```typescript
// Reusable behavior directives
@Directive({
selector: '[focusable]',
host: {
'tabindex': '0',
'(focus)': 'onFocus()',
'(blur)': 'onBlur()',
'[class.focused]': 'isFocused()',
},
})
export class Focusable {
isFocused = signal(false);
onFocus() {
this.isFocused.set(true);
}
onBlur() {
this.isFocused.set(false);
}
}
@Directive({
selector: '[disableable]',
host: {
'[class.disabled]': 'disabled()',
'[attr.aria-disabled]': 'disabled()',
},
})
export class Disableable {
disabled = input(false, { transform: booleanAttribute });
}
// Component using host directives
@Component({
selector: 'app-custom-button',
hostDirectives: [
Focusable,
{
directive: Disableable,
inputs: ['disabled'],
},
],
host: {
'role': 'button',
'(click)': 'onClick($event)',
'(keydown.enter)': 'onClick($event)',
'(keydown.space)': 'onClick($event)',
},
template: `<ng-content />`,
})
export class CustomButton {
private disableable = inject(Disableable);
clicked = output<void>();
onClick(event: Event) {
if (!this.disableable.disabled()) {
this.clicked.emit();
}
}
}
// Usage: <app-custom-button disabled>Click me</app-custom-button>
```
### Exposing Host Directive Outputs
```typescript
@Directive({
selector: '[hoverable]',
host: {
'(mouseenter)': 'onEnter()',
'(mouseleave)': 'onLeave()',
'[class.hovered]': 'isHovered()',
},
})
export class Hoverable {
isHovered = signal(false);
hoverChange = output<boolean>();
onEnter() {
this.isHovered.set(true);
this.hoverChange.emit(true);
}
onLeave() {
this.isHovered.set(false);
this.hoverChange.emit(false);
}
}
@Component({
selector: 'app-card',
hostDirectives: [
{
directive: Hoverable,
outputs: ['hoverChange'],
},
],
template: `<ng-content />`,
})
export class Card {}
// Usage: <app-card (hoverChange)="onHover($event)">...</app-card>
```
## Directive Composition API
Combine multiple behaviors:
```typescript
// Base directives
@Directive({ selector: '[withRipple]' })
export class Ripple {
// Ripple effect implementation
}
@Directive({ selector: '[withElevation]' })
export class Elevation {
elevation = input(2);
}
// Composed component
@Component({
selector: 'app-material-button',
hostDirectives: [
Ripple,
{
directive: Elevation,
inputs: ['elevation'],
},
{
directive: Disableable,
inputs: ['disabled'],
},
],
template: `<ng-content />`,
})
export class MaterialButton {}
```
For advanced patterns, see [references/directive-patterns.md](references/directive-patterns.md).
@@ -0,0 +1,575 @@
# Angular Directive Patterns
## Table of Contents
- [DOM Manipulation](#dom-manipulation)
- [Form Directives](#form-directives)
- [Intersection Observer](#intersection-observer)
- [Resize Observer](#resize-observer)
- [Drag and Drop](#drag-and-drop)
- [Permission Directive](#permission-directive)
## DOM Manipulation
### Auto-Focus Directive
```typescript
@Directive({
selector: '[appAutoFocus]',
})
export class AutoFocus {
private el = inject(ElementRef<HTMLElement>);
enabled = input(true, { alias: 'appAutoFocus', transform: booleanAttribute });
delay = input(0);
constructor() {
afterNextRender(() => {
if (this.enabled()) {
setTimeout(() => {
this.el.nativeElement.focus();
}, this.delay());
}
});
}
}
// Usage: <input appAutoFocus />
// Usage: <input [appAutoFocus]="shouldFocus()" [delay]="100" />
```
### Text Selection Directive
```typescript
@Directive({
selector: '[appSelectAll]',
host: {
'(focus)': 'onFocus()',
'(click)': 'onClick($event)',
},
})
export class SelectAll {
private el = inject(ElementRef<HTMLInputElement>);
onFocus() {
// Delay to ensure value is set
setTimeout(() => this.el.nativeElement.select(), 0);
}
onClick(event: MouseEvent) {
// Select all on first click if not already focused
if (document.activeElement !== this.el.nativeElement) {
this.el.nativeElement.select();
}
}
}
// Usage: <input appSelectAll value="Select me on focus" />
```
### Copy to Clipboard
```typescript
@Directive({
selector: '[appCopyToClipboard]',
host: {
'(click)': 'copy()',
'[style.cursor]': '"pointer"',
},
})
export class CopyToClipboard {
text = input.required<string>({ alias: 'appCopyToClipboard' });
copied = output<void>();
error = output<Error>();
async copy() {
try {
await navigator.clipboard.writeText(this.text());
this.copied.emit();
} catch (err) {
this.error.emit(err as Error);
}
}
}
// Usage:
// <button [appCopyToClipboard]="textToCopy" (copied)="showToast('Copied!')">
// Copy
// </button>
```
## Form Directives
### Trim Input
```typescript
@Directive({
selector: 'input[appTrim], textarea[appTrim]',
host: {
'(blur)': 'onBlur()',
},
})
export class Trim {
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
private ngControl = inject(NgControl, { optional: true, self: true });
onBlur() {
const value = this.el.nativeElement.value;
const trimmed = value.trim();
if (value !== trimmed) {
this.el.nativeElement.value = trimmed;
this.ngControl?.control?.setValue(trimmed);
}
}
}
// Usage: <input appTrim formControlName="name" />
```
### Input Mask
```typescript
@Directive({
selector: '[appMask]',
host: {
'(input)': 'onInput($event)',
'(keydown)': 'onKeydown($event)',
},
})
export class Mask {
private el = inject(ElementRef<HTMLInputElement>);
// Mask pattern: 9 = digit, A = letter, * = any
mask = input.required<string>({ alias: 'appMask' });
onInput(event: InputEvent) {
const input = this.el.nativeElement;
const value = input.value;
const masked = this.applyMask(value);
if (value !== masked) {
input.value = masked;
}
}
onKeydown(event: KeyboardEvent) {
// Allow navigation keys
if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(event.key)) {
return;
}
const input = this.el.nativeElement;
const position = input.selectionStart ?? 0;
const maskChar = this.mask()[position];
if (!maskChar) {
event.preventDefault();
return;
}
if (!this.isValidChar(event.key, maskChar)) {
event.preventDefault();
}
}
private applyMask(value: string): string {
const mask = this.mask();
let result = '';
let valueIndex = 0;
for (let i = 0; i < mask.length && valueIndex < value.length; i++) {
const maskChar = mask[i];
const inputChar = value[valueIndex];
if (maskChar === '9' || maskChar === 'A' || maskChar === '*') {
if (this.isValidChar(inputChar, maskChar)) {
result += inputChar;
valueIndex++;
} else {
valueIndex++;
i--;
}
} else {
result += maskChar;
if (inputChar === maskChar) {
valueIndex++;
}
}
}
return result;
}
private isValidChar(char: string, maskChar: string): boolean {
switch (maskChar) {
case '9':
return /\d/.test(char);
case 'A':
return /[a-zA-Z]/.test(char);
case '*':
return /[a-zA-Z0-9]/.test(char);
default:
return char === maskChar;
}
}
}
// Usage: <input appMask="(999) 999-9999" placeholder="(555) 123-4567" />
```
### Character Counter
```typescript
@Directive({
selector: '[appCharCount]',
})
export class CharCount {
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
maxLength = input.required<number>({ alias: 'appCharCount' });
currentLength = signal(0);
remaining = computed(() => this.maxLength() - this.currentLength());
isOverLimit = computed(() => this.remaining() < 0);
constructor() {
effect(() => {
this.currentLength.set(this.el.nativeElement.value.length);
});
// Listen for input changes
afterNextRender(() => {
this.el.nativeElement.addEventListener('input', () => {
this.currentLength.set(this.el.nativeElement.value.length);
});
});
}
}
// Usage with template:
// <textarea appCharCount="500" #counter="appCharCount"></textarea>
// <span>{{ counter.remaining() }} characters remaining</span>
```
## Intersection Observer
### Lazy Load Directive
```typescript
@Directive({
selector: '[appLazyLoad]',
})
export class LazyLoad implements OnDestroy {
private el = inject(ElementRef<HTMLElement>);
private observer: IntersectionObserver | null = null;
src = input.required<string>({ alias: 'appLazyLoad' });
placeholder = input('/assets/placeholder.png');
loaded = output<void>();
constructor() {
afterNextRender(() => {
this.setupObserver();
});
}
private setupObserver() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage();
this.observer?.disconnect();
}
});
},
{ rootMargin: '50px' },
);
this.observer.observe(this.el.nativeElement);
// Set placeholder
if (this.el.nativeElement instanceof HTMLImageElement) {
this.el.nativeElement.src = this.placeholder();
}
}
private loadImage() {
const element = this.el.nativeElement;
if (element instanceof HTMLImageElement) {
element.src = this.src();
element.onload = () => this.loaded.emit();
} else {
element.style.backgroundImage = `url(${this.src()})`;
this.loaded.emit();
}
}
ngOnDestroy() {
this.observer?.disconnect();
}
}
// Usage: <img [appLazyLoad]="imageUrl" alt="Lazy loaded image" />
```
### Infinite Scroll
```typescript
@Directive({
selector: '[appInfiniteScroll]',
})
export class InfiniteScroll implements OnDestroy {
private el = inject(ElementRef<HTMLElement>);
private observer: IntersectionObserver | null = null;
threshold = input(0.1);
disabled = input(false);
scrolled = output<void>();
constructor() {
afterNextRender(() => {
this.setupObserver();
});
effect(() => {
if (this.disabled()) {
this.observer?.disconnect();
} else {
this.setupObserver();
}
});
}
private setupObserver() {
this.observer?.disconnect();
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !this.disabled()) {
this.scrolled.emit();
}
},
{ threshold: this.threshold() },
);
this.observer.observe(this.el.nativeElement);
}
ngOnDestroy() {
this.observer?.disconnect();
}
}
// Usage:
// <div class="list">
// @for (item of items(); track item.id) {
// <div>{{ item.name }}</div>
// }
// <div appInfiniteScroll (scrolled)="loadMore()" [disabled]="isLoading()">
// Loading...
// </div>
// </div>
```
## Resize Observer
```typescript
@Directive({
selector: '[appResize]',
})
export class Resize implements OnDestroy {
private el = inject(ElementRef<HTMLElement>);
private observer: ResizeObserver | null = null;
width = signal(0);
height = signal(0);
resized = output<{ width: number; height: number }>();
constructor() {
afterNextRender(() => {
this.observer = new ResizeObserver((entries) => {
const entry = entries[0];
const { width, height } = entry.contentRect;
this.width.set(width);
this.height.set(height);
this.resized.emit({ width, height });
});
this.observer.observe(this.el.nativeElement);
});
}
ngOnDestroy() {
this.observer?.disconnect();
}
}
// Usage:
// <div appResize #resize="appResize">
// Size: {{ resize.width() }}x{{ resize.height() }}
// </div>
```
## Drag and Drop
```typescript
@Directive({
selector: '[appDraggable]',
host: {
'draggable': 'true',
'[class.dragging]': 'isDragging()',
'(dragstart)': 'onDragStart($event)',
'(dragend)': 'onDragEnd($event)',
},
})
export class Draggable {
data = input<any>(null, { alias: 'appDraggable' });
effectAllowed = input<DataTransfer['effectAllowed']>('move');
isDragging = signal(false);
dragStart = output<DragEvent>();
dragEnd = output<DragEvent>();
onDragStart(event: DragEvent) {
this.isDragging.set(true);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = this.effectAllowed();
event.dataTransfer.setData('application/json', JSON.stringify(this.data()));
}
this.dragStart.emit(event);
}
onDragEnd(event: DragEvent) {
this.isDragging.set(false);
this.dragEnd.emit(event);
}
}
@Directive({
selector: '[appDropZone]',
host: {
'[class.drag-over]': 'isDragOver()',
'(dragover)': 'onDragOver($event)',
'(dragleave)': 'onDragLeave($event)',
'(drop)': 'onDrop($event)',
},
})
export class DropZone {
isDragOver = signal(false);
dropped = output<any>();
onDragOver(event: DragEvent) {
event.preventDefault();
this.isDragOver.set(true);
}
onDragLeave(event: DragEvent) {
this.isDragOver.set(false);
}
onDrop(event: DragEvent) {
event.preventDefault();
this.isDragOver.set(false);
const data = event.dataTransfer?.getData('application/json');
if (data) {
this.dropped.emit(JSON.parse(data));
}
}
}
// Usage:
// <div [appDraggable]="item">Drag me</div>
// <div appDropZone (dropped)="onItemDropped($event)">Drop here</div>
```
## Permission Directive
```typescript
@Directive({
selector: '[appHasPermission]',
})
export class HasPermission {
private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef);
private authService = inject(Auth);
private hasView = false;
permission = input.required<string | string[]>({ alias: 'appHasPermission' });
mode = input<'any' | 'all'>('any');
constructor() {
effect(() => {
const hasPermission = this.checkPermission();
if (hasPermission && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (!hasPermission && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
});
}
private checkPermission(): boolean {
const required = this.permission();
const permissions = Array.isArray(required) ? required : [required];
const userPermissions = this.authService.permissions();
if (this.mode() === 'all') {
return permissions.every((p) => userPermissions.includes(p));
}
return permissions.some((p) => userPermissions.includes(p));
}
}
// Usage:
// <button *appHasPermission="'admin'">Admin Only</button>
// <div *appHasPermission="['edit', 'delete']; mode: 'all'">Edit & Delete</div>
```
## Export Directive Reference
```typescript
@Directive({
selector: '[appToggle]',
exportAs: 'appToggle',
})
export class Toggle {
isOpen = signal(false);
toggle() {
this.isOpen.update((v) => !v);
}
open() {
this.isOpen.set(true);
}
close() {
this.isOpen.set(false);
}
}
// Usage:
// <div appToggle #toggle="appToggle">
// <button (click)="toggle.toggle()">Toggle</button>
// @if (toggle.isOpen()) {
// <div>Content</div>
// }
// </div>
```
+452
View File
@@ -0,0 +1,452 @@
---
name: angular-forms
description: Build signal-based forms in Angular v21+ using the new Signal Forms API. Use for form creation with automatic two-way binding, schema-based validation, field state management, and dynamic forms. Triggers on form implementation, adding validation, creating multi-step forms, or building forms with conditional fields. Signal Forms are experimental but recommended for new Angular projects. Don't use for template-driven forms without signals or third-party form libraries like Formly or ngx-formly.
---
# Angular Signal Forms
Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms provide automatic two-way binding, schema-based validation, and reactive field state.
**Note:** Signal Forms are experimental in Angular v21. For production apps requiring stability, see [references/form-patterns.md](references/form-patterns.md) for Reactive Forms patterns.
## Basic Setup
```typescript
import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';
interface LoginData {
email: string;
password: string;
}
@Component({
selector: 'app-login',
imports: [FormField],
template: `
<form (submit)="onSubmit($event)">
<label>
Email
<input
type="email"
[formField]="loginForm.email" />
</label>
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
}
<label>
Password
<input
type="password"
[formField]="loginForm.password" />
</label>
@if (loginForm.password().touched() && loginForm.password().invalid()) {
<p class="error">{{ loginForm.password().errors()[0].message }}</p>
}
<button
type="submit"
[disabled]="loginForm().invalid()">
Login
</button>
</form>
`,
})
export class Login {
// Form model - a writable signal
loginModel = signal<LoginData>({
email: '',
password: '',
});
// Create form with validation schema
loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email, { message: 'Email is required' });
email(schemaPath.email, { message: 'Enter a valid email address' });
required(schemaPath.password, { message: 'Password is required' });
});
onSubmit(event: Event) {
event.preventDefault();
if (this.loginForm().valid()) {
const credentials = this.loginModel();
console.log('Submitting:', credentials);
}
}
}
```
## Form Models
Form models are writable signals that serve as the single source of truth:
```typescript
// Define interface for type safety
interface UserProfile {
name: string;
email: string;
age: number | null;
preferences: {
newsletter: boolean;
theme: 'light' | 'dark';
};
}
// Create model signal with initial values
const userModel = signal<UserProfile>({
name: '',
email: '',
age: null,
preferences: {
newsletter: false,
theme: 'light',
},
});
// Create form from model
const userForm = form(userModel);
// Access nested fields via dot notation
userForm.name; // FieldTree<string>
userForm.preferences.theme; // FieldTree<'light' | 'dark'>
```
### Reading Values
```typescript
// Read entire model
const data = this.userModel();
// Read field value via field state
const name = this.userForm.name().value();
const theme = this.userForm.preferences.theme().value();
```
### Updating Values
```typescript
// Replace entire model
this.userModel.set({
name: 'Alice',
email: 'alice@example.com',
age: 30,
preferences: { newsletter: true, theme: 'dark' },
});
// Update single field
this.userForm.name().value.set('Bob');
this.userForm.age().value.update((age) => (age ?? 0) + 1);
```
## Field State
Each field provides reactive signals for validation, interaction, and availability:
```typescript
const emailField = this.form.email();
// Validation state
emailField.valid(); // true if passes all validation
emailField.invalid(); // true if has validation errors
emailField.errors(); // array of error objects
emailField.pending(); // true if async validation in progress
// Interaction state
emailField.touched(); // true after focus + blur
emailField.dirty(); // true after user modification
// Availability state
emailField.disabled(); // true if field is disabled
emailField.hidden(); // true if field should be hidden
emailField.readonly(); // true if field is readonly
// Value
emailField.value(); // current field value (signal)
```
### Form-Level State
The form itself is also a field with aggregated state:
```typescript
// Form is valid when all interactive fields are valid
this.form().valid();
// Form is touched when any field is touched
this.form().touched();
// Form is dirty when any field is modified
this.form().dirty();
```
## Validation
### Built-in Validators
```typescript
import { form, required, email, min, max, minLength, maxLength, pattern } from '@angular/forms/signals';
const userForm = form(this.userModel, (schemaPath) => {
// Required field
required(schemaPath.name, { message: 'Name is required' });
// Email format
email(schemaPath.email, { message: 'Invalid email' });
// Numeric range
min(schemaPath.age, 18, { message: 'Must be 18+' });
max(schemaPath.age, 120, { message: 'Invalid age' });
// String/array length
minLength(schemaPath.password, 8, { message: 'Min 8 characters' });
maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });
// Regex pattern
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
message: 'Format: 555-123-4567',
});
});
```
### Conditional Validation
```typescript
const orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.promoCode, {
message: 'Promo code required for discounts',
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
});
});
```
### Custom Validators
```typescript
import { validate } from '@angular/forms/signals';
const signupForm = form(this.signupModel, (schemaPath) => {
// Custom validation logic
validate(schemaPath.username, ({ value }) => {
if (value().includes(' ')) {
return { kind: 'noSpaces', message: 'Username cannot contain spaces' };
}
return null;
});
});
```
### Cross-Field Validation
```typescript
const passwordForm = form(this.passwordModel, (schemaPath) => {
required(schemaPath.password);
required(schemaPath.confirmPassword);
// Compare fields
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(schemaPath.password)) {
return { kind: 'mismatch', message: 'Passwords do not match' };
}
return null;
});
});
```
### Async Validation
```typescript
import { validateHttp } from '@angular/forms/signals';
const signupForm = form(this.signupModel, (schemaPath) => {
validateHttp(schemaPath.username, {
request: ({ value }) => `/api/check-username?u=${value()}`,
onSuccess: (response: { taken: boolean }) => {
if (response.taken) {
return { kind: 'taken', message: 'Username already taken' };
}
return null;
},
onError: () => ({
kind: 'networkError',
message: 'Could not verify username',
}),
});
});
```
## Conditional Fields
### Hidden Fields
```typescript
import { hidden } from '@angular/forms/signals';
const profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
});
```
```html
@if (!profileForm.publicUrl().hidden()) {
<input [formField]="profileForm.publicUrl" />
}
```
### Disabled Fields
```typescript
import { disabled } from '@angular/forms/signals';
const orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
});
```
### Readonly Fields
```typescript
import { readonly } from '@angular/forms/signals';
const accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username); // Always readonly
});
```
## Form Submission
```typescript
import { submit } from '@angular/forms/signals';
@Component({
template: `
<form (submit)="onSubmit($event)">
<input [formField]="form.email" />
<input [formField]="form.password" />
<button
type="submit"
[disabled]="form().invalid()">
Submit
</button>
</form>
`,
})
export class Login {
model = signal({ email: '', password: '' });
form = form(this.model, (schemaPath) => {
required(schemaPath.email);
required(schemaPath.password);
});
onSubmit(event: Event) {
event.preventDefault();
// submit() marks all fields touched and runs callback if valid
submit(this.form, async () => {
await this.authService.login(this.model());
});
}
}
```
## Arrays and Dynamic Fields
```typescript
interface Order {
items: Array<{ product: string; quantity: number }>;
}
@Component({
template: `
@for (item of orderForm.items; track $index; let i = $index) {
<div>
<input
placeholder="Product"
[formField]="item.product" />
<input
type="number"
[formField]="item.quantity" />
<button
type="button"
(click)="removeItem(i)">
Remove
</button>
</div>
}
<button
type="button"
(click)="addItem()">
Add Item
</button>
`,
})
export class Order {
orderModel = signal<Order>({
items: [{ product: '', quantity: 1 }],
});
orderForm = form(this.orderModel, (schemaPath) => {
applyEach(schemaPath.items, (item) => {
required(item.product, { message: 'Product required' });
min(item.quantity, 1, { message: 'Min quantity is 1' });
});
});
addItem() {
this.orderModel.update((m) => ({
...m,
items: [...m.items, { product: '', quantity: 1 }],
}));
}
removeItem(index: number) {
this.orderModel.update((m) => ({
...m,
items: m.items.filter((_, i) => i !== index),
}));
}
}
```
## Displaying Errors
```html
<input [formField]="form.email" />
@if (form.email().touched() && form.email().invalid()) {
<ul class="errors">
@for (error of form.email().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
} @if (form.email().pending()) {
<span>Validating...</span>
}
```
## Styling Based on State
```html
<input
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()" />
```
## Reset Form
```typescript
async onSubmit() {
if (!this.form().valid()) return;
await this.api.submit(this.model());
// Clear interaction state
this.form().reset();
// Clear values
this.model.set({ email: '', password: '' });
}
```
For Reactive Forms patterns (production-stable), see [references/form-patterns.md](references/form-patterns.md).
@@ -0,0 +1,443 @@
# Angular Form Patterns
## Table of Contents
- [Reactive Forms (Production-Stable)](#reactive-forms-production-stable)
- [Typed Reactive Forms](#typed-reactive-forms)
- [FormBuilder Patterns](#formbuilder-patterns)
- [Dynamic Forms with FormArray](#dynamic-forms-with-formarray)
- [Custom Validators](#custom-validators)
- [Form State Management](#form-state-management)
## Reactive Forms (Production-Stable)
For production applications requiring stability guarantees, use Reactive Forms:
```typescript
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `
<form
[formGroup]="form"
(ngSubmit)="onSubmit()">
<input formControlName="email" />
@if (form.controls.email.errors?.['required'] && form.controls.email.touched) {
<span class="error">Email is required</span>
}
<input
type="password"
formControlName="password" />
<button
type="submit"
[disabled]="form.invalid">
Login
</button>
</form>
`,
})
export class Login {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
}
}
}
```
## Typed Reactive Forms
### Typed FormControl
```typescript
import { FormControl } from '@angular/forms';
// Inferred type: FormControl<string | null>
const name = new FormControl('');
// Non-nullable (no reset to null)
const email = new FormControl('', { nonNullable: true });
// Type: FormControl<string>
// With validators
const username = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3)],
});
```
### Typed FormGroup
```typescript
import { FormGroup, FormControl } from '@angular/forms';
interface UserForm {
name: FormControl<string>;
email: FormControl<string>;
age: FormControl<number | null>;
}
const form = new FormGroup<UserForm>({
name: new FormControl('', { nonNullable: true }),
email: new FormControl('', { nonNullable: true }),
age: new FormControl<number | null>(null),
});
// Typed value access
const name: string = form.controls.name.value;
```
### NonNullableFormBuilder
```typescript
import { inject } from '@angular/core';
import { NonNullableFormBuilder } from '@angular/forms';
@Component({...})
export class Profile {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
name: ['', Validators.required], // FormControl<string>
email: ['', [Validators.required, Validators.email]],
preferences: this.fb.group({
newsletter: [false], // FormControl<boolean>
theme: ['light' as 'light' | 'dark'], // FormControl<'light' | 'dark'>
}),
});
}
```
## FormBuilder Patterns
### Nested FormGroups
```typescript
@Component({
imports: [ReactiveFormsModule],
template: `
<form
[formGroup]="form"
(ngSubmit)="onSubmit()">
<input
formControlName="name"
placeholder="Name" />
<div formGroupName="address">
<input
formControlName="street"
placeholder="Street" />
<input
formControlName="city"
placeholder="City" />
<input
formControlName="zip"
placeholder="ZIP" />
</div>
<button type="submit">Submit</button>
</form>
`,
})
export class Profile {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
name: ['', Validators.required],
address: this.fb.group({
street: [''],
city: ['', Validators.required],
zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
}),
});
}
```
## Dynamic Forms with FormArray
```typescript
import { FormArray } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input
formControlName="product"
placeholder="Product" />
<input
formControlName="quantity"
type="number" />
<button
type="button"
(click)="removeItem(i)">
Remove
</button>
</div>
}
</div>
<button
type="button"
(click)="addItem()">
Add Item
</button>
</form>
`,
})
export class Order {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
items: this.fb.array([this.createItem()]),
});
get items() {
return this.form.controls.items;
}
createItem() {
return this.fb.group({
product: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
});
}
addItem() {
this.items.push(this.createItem());
}
removeItem(index: number) {
this.items.removeAt(index);
}
}
```
## Custom Validators
### Sync Validator
```typescript
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenValue(forbidden: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return control.value === forbidden
? { forbiddenValue: { value: control.value } }
: null;
};
}
// Usage
name: ['', [Validators.required, forbiddenValue('admin')]],
```
### Cross-Field Validator
```typescript
export function passwordMatch(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
};
}
// Usage
form = this.fb.group(
{
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
},
{ validators: passwordMatch() },
);
```
### Async Validator
```typescript
import { AsyncValidatorFn } from '@angular/forms';
import { map, catchError, of } from 'rxjs';
export function uniqueEmail(userService: User): AsyncValidatorFn {
return (control: AbstractControl) => {
return userService.checkEmail(control.value).pipe(
map(exists => exists ? { emailTaken: true } : null),
catchError(() => of(null))
);
};
}
// Usage
email: ['',
[Validators.required, Validators.email], // sync validators
[uniqueEmail(this.userService)] // async validators
],
```
## Form State Management
### State Properties
```typescript
// Check states
form.valid; // All validations pass
form.invalid; // Has validation errors
form.pending; // Async validation in progress
form.dirty; // Value changed by user
form.pristine; // Value not changed
form.touched; // Control has been focused
form.untouched; // Control never focused
// Update values
form.setValue({ name: 'John', email: 'john@example.com' }); // Must include all
form.patchValue({ name: 'John' }); // Partial update
// Reset
form.reset();
form.reset({ name: 'Default' });
// Disable/Enable
form.disable();
form.enable();
form.controls.email.disable();
// Mark states
form.markAllAsTouched(); // Show all errors
form.markAsPristine();
form.markAsDirty();
```
### Value Changes Observable
```typescript
// Subscribe to value changes
form.valueChanges.subscribe((value) => {
console.log('Form value:', value);
});
// Single control with debounce
form.controls.email.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((email) => {
this.validateEmail(email);
});
// Status changes
form.statusChanges.subscribe((status) => {
console.log('Form status:', status); // VALID, INVALID, PENDING
});
```
### Unified Events (Angular v18+)
```typescript
import {
ValueChangeEvent,
StatusChangeEvent,
PristineChangeEvent,
TouchedChangeEvent,
FormSubmittedEvent,
FormResetEvent,
} from '@angular/forms';
form.events.subscribe((event) => {
if (event instanceof ValueChangeEvent) {
console.log('Value changed:', event.value);
}
if (event instanceof StatusChangeEvent) {
console.log('Status changed:', event.status);
}
if (event instanceof PristineChangeEvent) {
console.log('Pristine changed:', event.pristine);
}
if (event instanceof TouchedChangeEvent) {
console.log('Touched changed:', event.touched);
}
if (event instanceof FormSubmittedEvent) {
console.log('Form submitted');
}
if (event instanceof FormResetEvent) {
console.log('Form reset');
}
});
```
## Error Display Pattern
```typescript
@Component({
template: `
<input formControlName="email" />
@if (form.controls.email.invalid && form.controls.email.touched) {
<div class="errors">
@if (form.controls.email.errors?.['required']) {
<span>Email is required</span>
}
@if (form.controls.email.errors?.['email']) {
<span>Invalid email format</span>
}
</div>
}
`,
})
export class Form {
// Helper for cleaner templates
hasError(controlName: string, errorKey: string): boolean {
const control = this.form.get(controlName);
return (control?.hasError(errorKey) && control?.touched) || false;
}
}
```
## Form Submission Pattern
```typescript
@Component({
template: `
<form
[formGroup]="form"
(ngSubmit)="onSubmit()">
<!-- fields -->
<button
type="submit"
[disabled]="form.invalid || isSubmitting">
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</button>
</form>
`,
})
export class Form {
isSubmitting = false;
async onSubmit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.isSubmitting = true;
try {
await this.api.submit(this.form.getRawValue());
this.form.reset();
} catch (error) {
// Handle error
} finally {
this.isSubmitting = false;
}
}
}
```
@@ -0,0 +1,101 @@
# Angular Signal Forms - ( FormValueControl )
## Table of Contents
- [Signal Form FormValueControl](#formValueControl)
## Signal Forms FormValueControl
```typescript
interface Rating {
rating: number;
}
import { form, FormField, FormValueControl, ValidationError, WithOptionalField } from '@angular/forms/signals';
import { MatIconModule } from '@angular/material/icon';
import { MatError } from '@angular/material/form-field';
@Component({
selector: 'app-rating',
imports: [MatIconModule, MatError],
template: `
<div class="star-rating-container">
@for (star of starArray(); track $index) {
<mat-icon
class="star-icon"
[class.readonly]="readonly()"
[class.error]="invalid()"
[class]="{ filled: star <= value() }"
(click)="rate(star)">
{{ getStarIcon(star) }}
</mat-icon>
}
@if (errors().at(0)?.message) {
<mat-error>
{{ errors().at(0)?.message }}
</mat-error>
}
</div>
`,
styles: ``,
})
export class Rating implements FormValueControl<number> {
// Required: The value of the control, exposed as a two-way binding.
readonly value = model<number>(0);
// Optional: Bindings for other form control states.
readonly readonly = input<boolean>(false);
readonly invalid = input<boolean>(false);
readonly errors: InputSignal<readonly WithOptionalField<ValidationError>[]> = input<readonly WithOptionalField<ValidationError>[]>([]);
starArray: Signal<number[]> = signal(
Array(5)
.fill(0)
.map((_, i) => i + 1),
);
getStarIcon(index: number): string {
const floorRating = Math.floor(this.value());
if (index <= floorRating) {
return 'star'; // Full star
} else {
return 'star_border'; // Empty star
}
}
rate(index: number): void {
if (!this.readonly()) {
this.value.set(index);
}
}
}
import { FormField } from '@angular/forms/signals';
@Component({
selector: 'app-signal-forms',
imports: [FormField, Rating],
template: `
<form
autocomplete="off"
(submit)="submit($event)">
<div class="form-field">
<app-rating [formField]="ratingForm.rating"> </app-rating>
<!-- print to show the value updation -->
{{ ratingForm.rating().value() }}
</div>
</form>
`,
styles: ``,
})
export class SignalForms {
readonly ratingModel = signal<Rating>({
rating: 0,
});
readonly ratingForm = form(this.ratingModel);
submit(event: Event): void {
event.preventDefault();
console.log(this.ratingForm.rating().value());
}
}
```
+355
View File
@@ -0,0 +1,355 @@
---
name: angular-http
description: Implement HTTP data fetching in Angular v20+ using resource(), httpResource(), and HttpClient. Use for API calls, data loading with signals, request/response handling, and interceptors. Triggers on data fetching, API integration, loading states, error handling, or converting Observable-based HTTP to signal-based patterns.
---
# Angular HTTP & Data Fetching
Fetch data in Angular using signal-based `resource()`, `httpResource()`, and the traditional `HttpClient`.
## httpResource() - Signal-Based HTTP
`httpResource()` wraps HttpClient with signal-based state management:
```typescript
import { Component, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
interface User {
id: number;
name: string;
email: string;
}
@Component({
selector: 'app-user-profile',
template: `
@if (userResource.isLoading()) {
<p>Loading...</p>
} @else if (userResource.error()) {
<p>Error: {{ userResource.error()?.message }}</p>
<button (click)="userResource.reload()">Retry</button>
} @else if (userResource.hasValue()) {
<h1>{{ userResource.value().name }}</h1>
<p>{{ userResource.value().email }}</p>
}
`,
})
export class UserProfile {
userId = signal('123');
// Reactive HTTP resource - refetches when userId changes
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
}
```
### httpResource Options
```typescript
// Simple GET request
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
// With full request options
userResource = httpResource<User>(() => ({
url: `/api/users/${this.userId()}`,
method: 'GET',
headers: { Authorization: `Bearer ${this.token()}` },
params: { include: 'profile' },
}));
// With default value
usersResource = httpResource<User[]>(() => '/api/users', {
defaultValue: [],
});
// Skip request when params undefined
userResource = httpResource<User>(() => {
const id = this.userId();
return id ? `/api/users/${id}` : undefined;
});
```
### Resource State
```typescript
// Status signals
userResource.value(); // Current value or undefined
userResource.hasValue(); // Boolean - has resolved value
userResource.error(); // Error or undefined
userResource.isLoading(); // Boolean - currently loading
userResource.status(); // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local'
// Actions
userResource.reload(); // Manually trigger reload
userResource.set(value); // Set local value
userResource.update(fn); // Update local value
```
## resource() - Generic Async Data
For non-HTTP async operations or custom fetch logic:
```typescript
import { resource, signal } from '@angular/core';
@Component({...})
export class Search {
query = signal('');
searchResource = resource({
// Reactive params - triggers reload when changed
params: () => ({ q: this.query() }),
// Async loader function
loader: async ({ params, abortSignal }) => {
if (!params.q) return [];
const response = await fetch(`/api/search?q=${params.q}`, {
signal: abortSignal,
});
return response.json() as Promise<SearchResult[]>;
},
});
}
```
### Resource with Default Value
```typescript
todosResource = resource({
defaultValue: [] as Todo[],
params: () => ({ filter: this.filter() }),
loader: async ({ params }) => {
const res = await fetch(`/api/todos?filter=${params.filter}`);
return res.json();
},
});
// value() returns Todo[] (never undefined)
```
### Conditional Loading
```typescript
const userId = signal<string | null>(null);
userResource = resource({
params: () => {
const id = userId();
// Return undefined to skip loading
return id ? { id } : undefined;
},
loader: async ({ params }) => {
return fetch(`/api/users/${params.id}`).then((r) => r.json());
},
});
// Status is 'idle' when params returns undefined
```
## HttpClient - Traditional Approach
For complex scenarios or when you need Observable operators:
```typescript
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({...})
export class Users {
private http = inject(HttpClient);
// Convert Observable to Signal
users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: [] }
);
// Or use Observable directly
users$ = this.http.get<User[]>('/api/users');
}
```
### HTTP Methods
```typescript
private http = inject(HttpClient);
// GET
getUser(id: string) {
return this.http.get<User>(`/api/users/${id}`);
}
// POST
createUser(user: CreateUserDto) {
return this.http.post<User>('/api/users', user);
}
// PUT
updateUser(id: string, user: UpdateUserDto) {
return this.http.put<User>(`/api/users/${id}`, user);
}
// PATCH
patchUser(id: string, changes: Partial<User>) {
return this.http.patch<User>(`/api/users/${id}`, changes);
}
// DELETE
deleteUser(id: string) {
return this.http.delete<void>(`/api/users/${id}`);
}
```
### Request Options
```typescript
this.http.get<User[]>('/api/users', {
headers: {
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
},
params: {
page: '1',
limit: '10',
sort: 'name',
},
observe: 'response', // Get full HttpResponse
responseType: 'json',
});
```
## Interceptors
### Functional Interceptor (Recommended)
```typescript
// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(Auth);
const token = authService.token();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next(req);
};
// error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
inject(Router).navigate(['/login']);
}
return throwError(() => error);
}),
);
};
// logging.interceptor.ts
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const started = Date.now();
return next(req).pipe(
tap({
next: () => console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`),
error: (err) => console.error(`${req.method} ${req.url} failed`, err),
}),
);
};
```
### Register Interceptors
```typescript
// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor]))],
};
```
## Error Handling
### With httpResource
```typescript
@Component({
template: `
@if (userResource.error(); as error) {
<div class="error">
<p>{{ getErrorMessage(error) }}</p>
<button (click)="userResource.reload()">Retry</button>
</div>
}
`,
})
export class UserCmpt {
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
getErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
return error.error?.message || `Error ${error.status}: ${error.statusText}`;
}
return 'An unexpected error occurred';
}
}
```
### With HttpClient
```typescript
import { catchError, retry } from 'rxjs';
getUser(id: string) {
return this.http.get<User>(`/api/users/${id}`).pipe(
retry(2), // Retry up to 2 times
catchError((error: HttpErrorResponse) => {
console.error('Error fetching user:', error);
return throwError(() => new Error('Failed to load user'));
})
);
}
```
## Loading States Pattern
```typescript
@Component({
template: `
@switch (dataResource.status()) {
@case ('idle') {
<p>Enter a search term</p>
}
@case ('loading') {
<app-spinner />
}
@case ('reloading') {
<app-data [data]="dataResource.value()" />
<app-spinner size="small" />
}
@case ('resolved') {
<app-data [data]="dataResource.value()" />
}
@case ('error') {
<app-error
[error]="dataResource.error()"
(retry)="dataResource.reload()" />
}
}
`,
})
export class Data {
query = signal('');
dataResource = httpResource<Data[]>(() => (this.query() ? `/api/search?q=${this.query()}` : undefined));
}
```
For advanced patterns, see [references/http-patterns.md](references/http-patterns.md).
@@ -0,0 +1,451 @@
# Angular HTTP Patterns
## Table of Contents
- [Service Layer Pattern](#service-layer-pattern)
- [Caching Strategies](#caching-strategies)
- [Pagination](#pagination)
- [File Upload](#file-upload)
- [Request Cancellation](#request-cancellation)
- [Testing HTTP](#testing-http)
## Service Layer Pattern
Encapsulate HTTP logic in services:
```typescript
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { httpResource } from '@angular/common/http';
export interface User {
id: string;
name: string;
email: string;
}
@Injectable({ providedIn: 'root' })
export class User {
private http = inject(HttpClient);
private baseUrl = '/api/users';
// Current user ID for reactive fetching
private currentUserId = signal<string | null>(null);
// Reactive resource that updates when currentUserId changes
currentUser = httpResource<User>(() => {
const id = this.currentUserId();
return id ? `${this.baseUrl}/${id}` : undefined;
});
// Set current user to fetch
selectUser(id: string) {
this.currentUserId.set(id);
}
// CRUD operations
getAll() {
return this.http.get<User[]>(this.baseUrl);
}
getById(id: string) {
return this.http.get<User>(`${this.baseUrl}/${id}`);
}
create(user: Omit<User, 'id'>) {
return this.http.post<User>(this.baseUrl, user);
}
update(id: string, user: Partial<User>) {
return this.http.patch<User>(`${this.baseUrl}/${id}`, user);
}
delete(id: string) {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
```
## Caching Strategies
### Simple In-Memory Cache
```typescript
@Injectable({ providedIn: 'root' })
export class CachedUser {
private http = inject(HttpClient);
private cache = new Map<string, { data: User; timestamp: number }>();
private cacheDuration = 5 * 60 * 1000; // 5 minutes
getUser(id: string): Observable<User> {
const cached = this.cache.get(id);
if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
return of(cached.data);
}
return this.http.get<User>(`/api/users/${id}`).pipe(
tap((user) => {
this.cache.set(id, { data: user, timestamp: Date.now() });
}),
);
}
invalidateCache(id?: string) {
if (id) {
this.cache.delete(id);
} else {
this.cache.clear();
}
}
}
```
### Signal-Based Cache
```typescript
@Injectable({ providedIn: 'root' })
export class UserCache {
private http = inject(HttpClient);
// Cache as signal
private usersCache = signal<Map<string, User>>(new Map());
// Computed for easy access
users = computed(() => Array.from(this.usersCache().values()));
getUser(id: string): User | undefined {
return this.usersCache().get(id);
}
async fetchUser(id: string): Promise<User> {
const cached = this.getUser(id);
if (cached) return cached;
const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
this.usersCache.update((cache) => {
const newCache = new Map(cache);
newCache.set(id, user);
return newCache;
});
return user;
}
}
```
## Pagination
### Paginated Resource
```typescript
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
@Component({
template: `
@if (usersResource.isLoading()) {
<app-spinner />
} @else if (usersResource.hasValue()) {
<ul>
@for (user of usersResource.value().data; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
<div class="pagination">
<button
[disabled]="page() === 1"
(click)="prevPage()">
Previous
</button>
<span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>
<button
[disabled]="page() >= usersResource.value().totalPages"
(click)="nextPage()">
Next
</button>
</div>
}
`,
})
export class UsersList {
page = signal(1);
pageSize = signal(10);
usersResource = httpResource<PaginatedResponse<User>>(() => ({
url: '/api/users',
params: {
page: this.page().toString(),
pageSize: this.pageSize().toString(),
},
}));
nextPage() {
this.page.update((p) => p + 1);
}
prevPage() {
this.page.update((p) => Math.max(1, p - 1));
}
}
```
### Infinite Scroll
```typescript
@Component({
template: `
<ul>
@for (user of allUsers(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
@if (isLoading()) {
<app-spinner />
}
@if (hasMore()) {
<button (click)="loadMore()">Load More</button>
}
`,
})
export class InfiniteUsers {
private http = inject(HttpClient);
private page = signal(1);
private users = signal<User[]>([]);
private totalPages = signal(1);
allUsers = this.users.asReadonly();
isLoading = signal(false);
hasMore = computed(() => this.page() < this.totalPages());
constructor() {
this.loadPage(1);
}
loadMore() {
this.loadPage(this.page() + 1);
}
private async loadPage(page: number) {
this.isLoading.set(true);
try {
const response = await firstValueFrom(
this.http.get<PaginatedResponse<User>>('/api/users', {
params: { page: page.toString(), pageSize: '20' },
}),
);
this.users.update((users) => [...users, ...response.data]);
this.page.set(page);
this.totalPages.set(response.totalPages);
} finally {
this.isLoading.set(false);
}
}
}
```
## File Upload
### Single File Upload
```typescript
@Component({
template: `
<input
type="file"
(change)="onFileSelected($event)" />
@if (uploadProgress() !== null) {
<progress
max="100"
[value]="uploadProgress()"></progress>
}
`,
})
export class FileUpload {
private http = inject(HttpClient);
uploadProgress = signal<number | null>(null);
onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
this.http
.post('/api/upload', formData, {
reportProgress: true,
observe: 'events',
})
.subscribe((event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
this.uploadProgress.set(Math.round((100 * event.loaded) / event.total));
} else if (event.type === HttpEventType.Response) {
this.uploadProgress.set(null);
console.log('Upload complete:', event.body);
}
});
}
}
```
### Multiple Files
```typescript
uploadFiles(files: FileList) {
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
return this.http.post<{ urls: string[] }>('/api/upload-multiple', formData);
}
```
## Request Cancellation
### With resource()
```typescript
// resource() automatically handles cancellation via abortSignal
searchResource = resource({
params: () => ({ q: this.query() }),
loader: async ({ params, abortSignal }) => {
const response = await fetch(`/api/search?q=${params.q}`, {
signal: abortSignal, // Cancels if params change
});
return response.json();
},
});
```
### With HttpClient
```typescript
@Component({...})
export class Search implements OnDestroy {
private http = inject(HttpClient);
private destroyRef = inject(DestroyRef);
query = signal('');
results = signal<Result[]>([]);
private searchSubscription?: Subscription;
search() {
// Cancel previous request
this.searchSubscription?.unsubscribe();
this.searchSubscription = this.http
.get<Result[]>(`/api/search?q=${this.query()}`)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(results => this.results.set(results));
}
}
```
### Debounced Search
```typescript
@Component({...})
export class SearchDebounced {
query = signal('');
private http = inject(HttpClient);
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
filter(q => q.length >= 2),
switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`)),
catchError(() => of([]))
),
{ initialValue: [] }
);
}
```
## Testing HTTP
### Testing httpResource
```typescript
describe('UserCmpt', () => {
let component: UserCmpt;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [provideHttpClientTesting()],
});
component = TestBed.createComponent(UserCmpt).componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});
it('should load user', () => {
component.userId.set('123');
const req = httpMock.expectOne('/api/users/123');
req.flush({ id: '123', name: 'Test User' });
expect(component.userResource.value()?.name).toBe('Test User');
});
afterEach(() => {
httpMock.verify();
});
});
```
### Testing Services
```typescript
describe('User', () => {
let service: User;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [User, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(User);
httpMock = TestBed.inject(HttpTestingController);
});
it('should create user', () => {
const newUser = { name: 'Test', email: 'test@example.com' };
service.create(newUser).subscribe((user) => {
expect(user.id).toBeDefined();
expect(user.name).toBe('Test');
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush({ id: '1', ...newUser });
});
});
```
+400
View File
@@ -0,0 +1,400 @@
---
name: angular-routing
description: Implement routing in Angular v20+ applications with lazy loading, functional guards, resolvers, and route parameters. Use for navigation setup, protected routes, route-based data loading, and nested routing. Triggers on route configuration, adding authentication guards, implementing lazy loading, or reading route parameters with signals.
---
# Angular Routing
Configure routing in Angular v20+ with lazy loading, functional guards, and signal-based route parameters.
## Basic Setup
```typescript
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'home', component: Home },
{ path: 'about', component: About },
{ path: '**', component: NotFound },
];
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes)],
};
// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<nav>
<a
routerLink="/home"
routerLinkActive="active"
>Home</a
>
<a
routerLink="/about"
routerLinkActive="active"
>About</a
>
</nav>
<router-outlet />
`,
})
export class App {}
```
## Lazy Loading
Load feature modules on demand:
```typescript
// app.routes.ts
export const routes: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'home', component: Home },
// Lazy load entire feature
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then((m) => m.adminRoutes),
},
// Lazy load single component
{
path: 'settings',
loadComponent: () => import('./settings/settings.component').then((m) => m.Settings),
},
];
// admin/admin.routes.ts
export const adminRoutes: Routes = [
{ path: '', component: AdminDashboard },
{ path: 'users', component: AdminUsers },
{ path: 'settings', component: AdminSettings },
];
```
## Route Parameters
### With Signal Inputs (Recommended)
```typescript
// Route config
{ path: 'users/:id', component: UserDetail }
// Component - use input() for route params
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-user-detail',
template: `
<h1>User {{ id() }}</h1>
`,
})
export class UserDetail {
// Route param as signal input
id = input.required<string>();
// Computed based on route param
userId = computed(() => parseInt(this.id(), 10));
}
```
Enable with `withComponentInputBinding()`:
```typescript
// app.config.ts
import { provideRouter, withComponentInputBinding } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withComponentInputBinding())],
};
```
### Query Parameters
```typescript
// Route: /search?q=angular&page=1
@Component({...})
export class Search {
// Query params as inputs
q = input<string>('');
page = input<string>('1');
currentPage = computed(() => parseInt(this.page(), 10));
}
```
### With ActivatedRoute (Alternative)
```typescript
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
@Component({...})
export class UserDetail {
private route = inject(ActivatedRoute);
// Convert route params to signal
id = toSignal(
this.route.paramMap.pipe(map(params => params.get('id'))),
{ initialValue: null }
);
// Query params
query = toSignal(
this.route.queryParamMap.pipe(map(params => params.get('q'))),
{ initialValue: '' }
);
}
```
## Functional Guards
### Auth Guard
```typescript
// guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(Auth);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
// Redirect to login with return URL
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
// Usage in routes
{
path: 'dashboard',
component: Dashboard,
canActivate: [authGuard],
}
```
### Role Guard
```typescript
export const roleGuard = (allowedRoles: string[]): CanActivateFn => {
return (route, state) => {
const authService = inject(Auth);
const router = inject(Router);
const userRole = authService.currentUser()?.role;
if (userRole && allowedRoles.includes(userRole)) {
return true;
}
return router.createUrlTree(['/unauthorized']);
};
};
// Usage
{
path: 'admin',
component: Admin,
canActivate: [authGuard, roleGuard(['admin', 'superadmin'])],
}
```
### Can Deactivate Guard
```typescript
export interface CanDeactivate {
canDeactivate: () => boolean | Promise<boolean>;
}
export const unsavedChangesGuard: CanDeactivateFn<CanDeactivate> = (component) => {
if (component.canDeactivate()) {
return true;
}
return confirm('You have unsaved changes. Leave anyway?');
};
// Component implementation
@Component({...})
export class Edit implements CanDeactivate {
form = inject(FormBuilder).group({...});
canDeactivate(): boolean {
return !this.form.dirty;
}
}
// Route
{
path: 'edit/:id',
component: Edit,
canDeactivate: [unsavedChangesGuard],
}
```
## Resolvers
Pre-fetch data before route activation:
```typescript
// resolvers/user.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
export const userResolver: ResolveFn<User> = (route) => {
const userService = inject(User);
const id = route.paramMap.get('id')!;
return userService.getById(id);
};
// Route config
{
path: 'users/:id',
component: UserDetail,
resolve: { user: userResolver },
}
// Component - access resolved data via input
@Component({...})
export class UserDetail {
user = input.required<User>();
}
```
## Nested Routes
```typescript
// Parent route with children
export const routes: Routes = [
{
path: 'products',
component: ProductsLayout,
children: [
{ path: '', component: ProductList },
{ path: ':id', component: ProductDetail },
{ path: ':id/edit', component: ProductEdit },
],
},
];
// ProductsLayout
@Component({
imports: [RouterOutlet],
template: `
<h1>Products</h1>
<router-outlet />
<!-- Child routes render here -->
`,
})
export class ProductsLayout {}
```
## Programmatic Navigation
```typescript
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({...})
export class Product {
private router = inject(Router);
// Navigate to route
goToProducts() {
this.router.navigate(['/products']);
}
// Navigate with params
goToProduct(id: string) {
this.router.navigate(['/products', id]);
}
// Navigate with query params
search(query: string) {
this.router.navigate(['/search'], {
queryParams: { q: query, page: 1 },
});
}
// Navigate relative to current route
goToEdit() {
this.router.navigate(['edit'], { relativeTo: this.route });
}
// Replace current history entry
replaceUrl() {
this.router.navigate(['/new-page'], { replaceUrl: true });
}
}
```
## Route Data
```typescript
// Static route data
{
path: 'admin',
component: Admin,
data: {
title: 'Admin Dashboard',
roles: ['admin'],
},
}
// Access in component
@Component({...})
export class AdminCmpt {
title = input<string>(); // From route data
roles = input<string[]>(); // From route data
}
// Or via ActivatedRoute
private route = inject(ActivatedRoute);
data = toSignal(this.route.data);
```
## Router Events
```typescript
import { Router, NavigationStart, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs';
@Component({...})
export class AppMain {
private router = inject(Router);
isNavigating = signal(false);
constructor() {
this.router.events.pipe(
filter(e => e instanceof NavigationStart || e instanceof NavigationEnd)
).subscribe(event => {
this.isNavigating.set(event instanceof NavigationStart);
});
}
}
```
For advanced patterns, see [references/routing-patterns.md](references/routing-patterns.md).
@@ -0,0 +1,457 @@
# Angular Routing Patterns
## Table of Contents
- [Route Configuration Options](#route-configuration-options)
- [Authentication Flow](#authentication-flow)
- [Breadcrumbs](#breadcrumbs)
- [Tab Navigation](#tab-navigation)
- [Modal Routes](#modal-routes)
- [Preloading Strategies](#preloading-strategies)
## Route Configuration Options
### Full Route Options
```typescript
{
path: 'users/:id',
component: UserCmpt,
// Lazy loading alternatives
loadComponent: () => import('./user.component').then(m => m.UserCmpt),
loadChildren: () => import('./user.routes').then(m => m.userRoutes),
// Guards
canActivate: [authGuard],
canActivateChild: [authGuard],
canDeactivate: [unsavedChangesGuard],
canMatch: [featureFlagGuard],
// Data
resolve: { user: userResolver },
data: { title: 'User Profile', animation: 'userPage' },
// Children
children: [...],
// Outlet
outlet: 'sidebar',
// Path matching
pathMatch: 'full', // or 'prefix'
// Title
title: 'User Profile',
// Or dynamic title
title: userTitleResolver,
}
```
### Dynamic Title Resolver
```typescript
export const userTitleResolver: ResolveFn<string> = (route) => {
const userService = inject(User);
const id = route.paramMap.get('id')!;
return userService.getById(id).pipe(map((user) => `${user.name} - Profile`));
};
```
## Authentication Flow
### Complete Auth Setup
```typescript
// auth.service.ts
@Injectable({ providedIn: 'root' })
export class Auth {
private _user = signal<User | null>(null);
private _token = signal<string | null>(null);
readonly user = this._user.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null);
private router = inject(Router);
private http = inject(HttpClient);
async login(credentials: Credentials): Promise<boolean> {
try {
const response = await firstValueFrom(this.http.post<AuthResponse>('/api/login', credentials));
this._token.set(response.token);
this._user.set(response.user);
localStorage.setItem('token', response.token);
return true;
} catch {
return false;
}
}
logout(): void {
this._user.set(null);
this._token.set(null);
localStorage.removeItem('token');
this.router.navigate(['/login']);
}
async checkAuth(): Promise<boolean> {
const token = localStorage.getItem('token');
if (!token) return false;
try {
const user = await firstValueFrom(this.http.get<User>('/api/me'));
this._user.set(user);
this._token.set(token);
return true;
} catch {
localStorage.removeItem('token');
return false;
}
}
}
// auth.guard.ts
export const authGuard: CanActivateFn = async (route, state) => {
const authService = inject(Auth);
const router = inject(Router);
// Check if already authenticated
if (authService.isAuthenticated()) {
return true;
}
// Try to restore session
const isValid = await authService.checkAuth();
if (isValid) {
return true;
}
// Redirect to login
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
// login.component.ts
@Component({
template: `
<form (ngSubmit)="login()">
<input
name="email"
[(ngModel)]="email" />
<input
name="password"
type="password"
[(ngModel)]="password" />
<button type="submit">Login</button>
</form>
`,
})
export class Login {
private authService = inject(Auth);
private router = inject(Router);
private route = inject(ActivatedRoute);
email = '';
password = '';
async login() {
const success = await this.authService.login({
email: this.email,
password: this.password,
});
if (success) {
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl);
}
}
}
```
## Breadcrumbs
```typescript
// breadcrumb.service.ts
@Injectable({ providedIn: 'root' })
export class Breadcrumb {
private router = inject(Router);
private route = inject(ActivatedRoute);
breadcrumbs = toSignal(
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map(() => this.buildBreadcrumbs(this.route.root)),
),
{ initialValue: [] },
);
private buildBreadcrumbs(route: ActivatedRoute, url: string = '', breadcrumbs: Breadcrumb[] = []): Breadcrumb[] {
const children = route.children;
if (children.length === 0) {
return breadcrumbs;
}
for (const child of children) {
const routeUrl = child.snapshot.url.map((segment) => segment.path).join('/');
if (routeUrl) {
url += `/${routeUrl}`;
}
const label = child.snapshot.data['breadcrumb'];
if (label) {
breadcrumbs.push({ label, url });
}
return this.buildBreadcrumbs(child, url, breadcrumbs);
}
return breadcrumbs;
}
}
// Route config with breadcrumb data
export const routes: Routes = [
{
path: 'products',
data: { breadcrumb: 'Products' },
children: [
{ path: '', component: ProductList },
{
path: ':id',
data: { breadcrumb: 'Product Details' },
component: ProductDetail,
},
],
},
];
// breadcrumb.component.ts
@Component({
selector: 'app-breadcrumb',
template: `
<nav aria-label="Breadcrumb">
<ol>
<li><a routerLink="/">Home</a></li>
@for (crumb of breadcrumbService.breadcrumbs(); track crumb.url) {
<li>
<a [routerLink]="crumb.url">{{ crumb.label }}</a>
</li>
}
</ol>
</nav>
`,
})
export class BreadcrumbCmpt {
breadcrumbService = inject(Breadcrumb);
}
```
## Tab Navigation
```typescript
// tabs-layout.component.ts
@Component({
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="tabs">
@for (tab of tabs; track tab.path) {
<a
[routerLink]="tab.path"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: tab.exact }"
>
{{ tab.label }}
</a>
}
</div>
<div class="tab-content">
<router-outlet />
</div>
`,
})
export class TabsLayout {
tabs = [
{ path: './', label: 'Overview', exact: true },
{ path: 'details', label: 'Details', exact: false },
{ path: 'settings', label: 'Settings', exact: false },
];
}
// Routes
{
path: 'account',
component: TabsLayout,
children: [
{ path: '', component: AccountOverview },
{ path: 'details', component: AccountDetails },
{ path: 'settings', component: AccountSettings },
],
}
```
## Modal Routes
Using auxiliary outlets for modals:
```typescript
// Routes
export const routes: Routes = [
{ path: 'products', component: ProductList },
{ path: 'product-modal/:id', component: ProductModal, outlet: 'modal' },
];
// App template
@Component({
template: `
<router-outlet />
<router-outlet name="modal" />
`,
})
export class App {}
// Open modal
this.router.navigate([{ outlets: { modal: ['product-modal', productId] } }]);
// Close modal
this.router.navigate([{ outlets: { modal: null } }]);
// Link to open modal
<a [routerLink]="[{ outlets: { modal: ['product-modal', product.id] } }]">
View Details
</a>
```
## Preloading Strategies
### Built-in Strategies
```typescript
import { provideRouter, withPreloading, PreloadAllModules, NoPreloading } from '@angular/router';
// Preload all lazy modules
provideRouter(routes, withPreloading(PreloadAllModules));
// No preloading (default)
provideRouter(routes, withPreloading(NoPreloading));
```
### Custom Preloading Strategy
```typescript
// selective-preload.strategy.ts
@Injectable({ providedIn: 'root' })
export class SelectivePreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// Only preload routes marked with data.preload = true
if (route.data?.['preload']) {
return load();
}
return of(null);
}
}
// Routes
{
path: 'dashboard',
loadComponent: () => import('./dashboard.component'),
data: { preload: true }, // Will be preloaded
}
// Config
provideRouter(routes, withPreloading(SelectivePreloadStrategy))
```
### Network-Aware Preloading
```typescript
@Injectable({ providedIn: 'root' })
export class NetworkAwarePreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// Check network conditions
const connection = (navigator as any).connection;
if (connection) {
// Don't preload on slow connections
if (connection.saveData || connection.effectiveType === '2g') {
return of(null);
}
}
// Preload if marked
if (route.data?.['preload']) {
return load();
}
return of(null);
}
}
```
## Route Animations
```typescript
// app.routes.ts
export const routes: Routes = [
{ path: 'home', component: Home, data: { animation: 'HomePage' } },
{ path: 'about', component: About, data: { animation: 'AboutPage' } },
];
// app.component.ts
@Component({
imports: [RouterOutlet],
template: `
<div [@routeAnimations]="getRouteAnimationData()">
<router-outlet />
</div>
`,
animations: [
trigger('routeAnimations', [
transition('HomePage <=> AboutPage', [
style({ position: 'relative' }),
query(':enter, :leave', [
style({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
}),
]),
query(':enter', [style({ left: '-100%' })]),
query(':leave', animateChild()),
group([
query(':leave', [animate('300ms ease-out', style({ left: '100%' }))]),
query(':enter', [animate('300ms ease-out', style({ left: '0%' }))]),
]),
]),
]),
],
})
export class AppMain {
getRouteAnimationData() {
return this.route.firstChild?.snapshot.data['animation'];
}
}
```
## Scroll Position Restoration
```typescript
// app.config.ts
import { provideRouter, withInMemoryScrolling, withRouterConfig } from '@angular/router';
provideRouter(
routes,
withInMemoryScrolling({
scrollPositionRestoration: 'enabled', // or 'top'
anchorScrolling: 'enabled',
}),
withRouterConfig({
onSameUrlNavigation: 'reload',
}),
);
```
+296
View File
@@ -0,0 +1,296 @@
---
name: angular-signals
description: Implement signal-based reactive state management in Angular v20+. Use for creating reactive state with signal(), derived state with computed(), dependent state with linkedSignal(), and side effects with effect(). Triggers on state management questions, converting from BehaviorSubject/Observable patterns to signals, or implementing reactive data flows.
---
# Angular Signals
Signals are Angular's reactive primitive for state management. They provide synchronous, fine-grained reactivity.
## Core Signal APIs
### signal() - Writable State
```typescript
import { signal } from '@angular/core';
// Create writable signal
const count = signal(0);
// Read value
console.log(count()); // 0
// Set new value
count.set(5);
// Update based on current value
count.update((c) => c + 1);
// With explicit type
const user = signal<User | null>(null);
user.set({ id: 1, name: 'Alice' });
```
### computed() - Derived State
```typescript
import { signal, computed } from '@angular/core';
const firstName = signal('John');
const lastName = signal('Doe');
// Derived signal - automatically updates when dependencies change
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // "John Doe"
firstName.set('Jane');
console.log(fullName()); // "Jane Doe"
// Computed with complex logic
const items = signal<Item[]>([]);
const filter = signal('');
const filteredItems = computed(() => {
const query = filter().toLowerCase();
return items().filter((item) => item.name.toLowerCase().includes(query));
});
const totalPrice = computed(() => filteredItems().reduce((sum, item) => sum + item.price, 0));
```
### linkedSignal() - Dependent State with Reset
```typescript
import { signal, linkedSignal } from '@angular/core';
const options = signal(['A', 'B', 'C']);
// Resets to first option when options change
const selected = linkedSignal(() => options()[0]);
console.log(selected()); // "A"
selected.set('B'); // User selects B
console.log(selected()); // "B"
options.set(['X', 'Y']); // Options change
console.log(selected()); // "X" - auto-reset to first
// With previous value access
const items = signal<Item[]>([]);
const selectedItem = linkedSignal<Item[], Item | null>({
source: () => items(),
computation: (newItems, previous) => {
// Try to preserve selection if item still exists
const prevItem = previous?.value;
if (prevItem && newItems.some((i) => i.id === prevItem.id)) {
return prevItem;
}
return newItems[0] ?? null;
},
});
```
### effect() - Side Effects
```typescript
import { signal, effect, inject, DestroyRef } from '@angular/core';
@Component({...})
export class Search {
query = signal('');
constructor() {
// Effect runs when query changes
effect(() => {
console.log('Search query:', this.query());
});
// Effect with cleanup
effect((onCleanup) => {
const timer = setInterval(() => {
console.log('Current query:', this.query());
}, 1000);
onCleanup(() => clearInterval(timer));
});
}
}
```
**Effect rules:**
- Run in injection context (constructor or with `runInInjectionContext`)
- Automatically cleaned up when component destroys
## Component State Pattern
```typescript
@Component({
selector: 'app-todo-list',
template: `
<input
[value]="newTodo()"
(input)="newTodo.set($any($event.target).value)" />
<button
[disabled]="!canAdd()"
(click)="addTodo()">
Add
</button>
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li [class.done]="todo.done">
{{ todo.text }}
<button (click)="toggleTodo(todo.id)">Toggle</button>
</li>
}
</ul>
<p>{{ remaining() }} remaining</p>
`,
})
export class TodoList {
// State
todos = signal<Todo[]>([]);
newTodo = signal('');
filter = signal<'all' | 'active' | 'done'>('all');
// Derived state
canAdd = computed(() => this.newTodo().trim().length > 0);
filteredTodos = computed(() => {
const todos = this.todos();
switch (this.filter()) {
case 'active':
return todos.filter((t) => !t.done);
case 'done':
return todos.filter((t) => t.done);
default:
return todos;
}
});
remaining = computed(() => this.todos().filter((t) => !t.done).length);
// Actions
addTodo() {
const text = this.newTodo().trim();
if (text) {
this.todos.update((todos) => [...todos, { id: crypto.randomUUID(), text, done: false }]);
this.newTodo.set('');
}
}
toggleTodo(id: string) {
this.todos.update((todos) => todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
}
```
## RxJS Interop
### toSignal() - Observable to Signal
```typescript
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({...})
export class Timer {
private http = inject(HttpClient);
// From observable - requires initial value or allowUndefined
counter = toSignal(interval(1000), { initialValue: 0 });
// From HTTP - undefined until loaded
users = toSignal(this.http.get<User[]>('/api/users'));
// With requireSync for synchronous observables (BehaviorSubject)
private user$ = new BehaviorSubject<User | null>(null);
currentUser = toSignal(this.user$, { requireSync: true });
}
```
### toObservable() - Signal to Observable
```typescript
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap, debounceTime } from 'rxjs';
@Component({...})
export class Search {
query = signal('');
private http = inject(HttpClient);
// Convert signal to observable for RxJS operators
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`))
),
{ initialValue: [] }
);
}
```
## Signal Equality
```typescript
// Custom equality function
const user = signal<User>({ id: 1, name: 'Alice' }, { equal: (a, b) => a.id === b.id });
// Only triggers updates when ID changes
user.set({ id: 1, name: 'Alice Updated' }); // No update
user.set({ id: 2, name: 'Bob' }); // Triggers update
```
## Untracked Reads
```typescript
import { untracked } from '@angular/core';
const a = signal(1);
const b = signal(2);
// Only depends on 'a', not 'b'
const result = computed(() => {
const aVal = a();
const bVal = untracked(() => b());
return aVal + bVal;
});
```
## Service State Pattern
```typescript
@Injectable({ providedIn: 'root' })
export class Auth {
// Private writable state
private _user = signal<User | null>(null);
private _loading = signal(false);
// Public read-only signals
readonly user = this._user.asReadonly();
readonly loading = this._loading.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null);
private http = inject(HttpClient);
async login(credentials: Credentials): Promise<void> {
this._loading.set(true);
try {
const user = await firstValueFrom(this.http.post<User>('/api/login', credentials));
this._user.set(user);
} finally {
this._loading.set(false);
}
}
logout(): void {
this._user.set(null);
}
}
```
For advanced patterns including resource(), see [references/signal-patterns.md](references/signal-patterns.md).
@@ -0,0 +1,391 @@
# Angular Signal Patterns
## Table of Contents
- [Resource API](#resource-api)
- [Signal Store Pattern](#signal-store-pattern)
- [Form State with Signals](#form-state-with-signals)
- [Async Operations](#async-operations)
- [Testing Signals](#testing-signals)
## Resource API
The `resource()` API handles async data fetching with signals:
```typescript
import { resource, signal, computed } from '@angular/core';
@Component({...})
export class UserProfile {
userId = signal<string>('');
// Resource fetches data when params change
userResource = resource({
params: () => ({ id: this.userId() }),
loader: async ({ params, abortSignal }) => {
const response = await fetch(`/api/users/${params.id}`, {
signal: abortSignal,
});
return response.json() as Promise<User>;
},
});
// Access resource state
user = computed(() => this.userResource.value());
isLoading = computed(() => this.userResource.isLoading());
error = computed(() => this.userResource.error());
}
```
### Resource Status
```typescript
const userResource = resource({...});
// Status signals
userResource.value(); // Current value or undefined
userResource.hasValue(); // Boolean - has resolved value
userResource.error(); // Error or undefined
userResource.isLoading(); // Boolean - currently loading
userResource.status(); // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local'
// Manual reload
userResource.reload();
// Local updates
userResource.set(newValue);
userResource.update(current => ({ ...current, name: 'Updated' }));
```
### Resource with Default Value
```typescript
const todosResource = resource({
defaultValue: [] as Todo[],
params: () => ({ filter: this.filter() }),
loader: async ({ params }) => {
const response = await fetch(`/api/todos?filter=${params.filter}`);
return response.json();
},
});
// value() returns Todo[] (never undefined due to defaultValue)
```
### Conditional Loading
```typescript
const userId = signal<string | null>(null);
const userResource = resource({
params: () => {
const id = userId();
// Return undefined to skip loading
return id ? { id } : undefined;
},
loader: async ({ params }) => {
return fetch(`/api/users/${params.id}`).then((r) => r.json());
},
});
// Status is 'idle' when params returns undefined
```
## Signal Store Pattern
For complex state, create a dedicated store:
```typescript
interface ProductState {
products: Product[];
selectedId: string | null;
filter: string;
loading: boolean;
error: string | null;
}
@Injectable({ providedIn: 'root' })
export class ProductSt {
// Private state
private state = signal<ProductState>({
products: [],
selectedId: null,
filter: '',
loading: false,
error: null,
});
// Selectors (computed signals)
readonly products = computed(() => this.state().products);
readonly selectedId = computed(() => this.state().selectedId);
readonly filter = computed(() => this.state().filter);
readonly loading = computed(() => this.state().loading);
readonly error = computed(() => this.state().error);
readonly filteredProducts = computed(() => {
const { products, filter } = this.state();
if (!filter) return products;
return products.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()));
});
readonly selectedProduct = computed(() => {
const { products, selectedId } = this.state();
return products.find((p) => p.id === selectedId) ?? null;
});
private http = inject(HttpClient);
// Actions
setFilter(filter: string): void {
this.state.update((s) => ({ ...s, filter }));
}
selectProduct(id: string | null): void {
this.state.update((s) => ({ ...s, selectedId: id }));
}
async loadProducts(): Promise<void> {
this.state.update((s) => ({ ...s, loading: true, error: null }));
try {
const products = await firstValueFrom(this.http.get<Product[]>('/api/products'));
this.state.update((s) => ({ ...s, products, loading: false }));
} catch (err) {
this.state.update((s) => ({
...s,
loading: false,
error: 'Failed to load products',
}));
}
}
async addProduct(product: Omit<Product, 'id'>): Promise<void> {
const newProduct = await firstValueFrom(this.http.post<Product>('/api/products', product));
this.state.update((s) => ({
...s,
products: [...s.products, newProduct],
}));
}
}
```
## Form State with Signals
```typescript
interface FormState<T> {
value: T;
touched: boolean;
dirty: boolean;
valid: boolean;
errors: string[];
}
function createFormField<T>(
initialValue: T,
validators: ((value: T) => string | null)[] = []
) {
const value = signal(initialValue);
const touched = signal(false);
const dirty = signal(false);
const errors = computed(() => {
return validators
.map(v => v(value()))
.filter((e): e is string => e !== null);
});
const valid = computed(() => errors().length === 0);
return {
value,
touched: touched.asReadonly(),
dirty: dirty.asReadonly(),
errors,
valid,
setValue(newValue: T) {
value.set(newValue);
dirty.set(true);
},
markTouched() {
touched.set(true);
},
reset() {
value.set(initialValue);
touched.set(false);
dirty.set(false);
},
};
}
// Usage
@Component({...})
export class Signup {
email = createFormField('', [
v => !v ? 'Email is required' : null,
v => !v.includes('@') ? 'Invalid email' : null,
]);
password = createFormField('', [
v => !v ? 'Password is required' : null,
v => v.length < 8 ? 'Password must be at least 8 characters' : null,
]);
formValid = computed(() =>
this.email.valid() && this.password.valid()
);
}
```
## Async Operations
### Debounced Search
```typescript
@Component({...})
export class Search {
query = signal('');
private http = inject(HttpClient);
// Debounced search using toObservable
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
filter(q => q.length >= 2),
switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`)),
catchError(() => of([]))
),
{ initialValue: [] }
);
// Loading state
private searching = signal(false);
readonly isSearching = this.searching.asReadonly();
constructor() {
// Track loading state
effect(() => {
const q = this.query();
if (q.length >= 2) {
this.searching.set(true);
}
});
effect(() => {
this.results(); // Subscribe to results
this.searching.set(false);
});
}
}
```
### Optimistic Updates
```typescript
@Injectable({ providedIn: 'root' })
export class Todo {
private todos = signal<Todo[]>([]);
readonly items = this.todos.asReadonly();
private http = inject(HttpClient);
async toggleTodo(id: string): Promise<void> {
// Optimistic update
const previousTodos = this.todos();
this.todos.update((todos) => todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
try {
await firstValueFrom(this.http.patch(`/api/todos/${id}/toggle`, {}));
} catch {
// Rollback on error
this.todos.set(previousTodos);
}
}
}
```
## Testing Signals
```typescript
describe('Counter', () => {
it('should increment count', () => {
const component = new Counter();
expect(component.count()).toBe(0);
component.increment();
expect(component.count()).toBe(1);
component.increment();
expect(component.count()).toBe(2);
});
it('should compute doubled value', () => {
const component = new Counter();
expect(component.doubled()).toBe(0);
component.count.set(5);
expect(component.doubled()).toBe(10);
});
});
describe('ProductSt', () => {
let store: ProductSt;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ProductSt, provideHttpClient(), provideHttpClientTesting()],
});
store = TestBed.inject(ProductSt);
httpMock = TestBed.inject(HttpTestingController);
});
it('should filter products', () => {
// Set initial state
store['state'].set({
products: [
{ id: '1', name: 'Apple' },
{ id: '2', name: 'Banana' },
],
selectedId: null,
filter: '',
loading: false,
error: null,
});
expect(store.filteredProducts().length).toBe(2);
store.setFilter('app');
expect(store.filteredProducts().length).toBe(1);
expect(store.filteredProducts()[0].name).toBe('Apple');
});
});
```
## Signal Debugging
```typescript
// Debug effect to log signal changes
effect(() => {
console.log('State changed:', {
count: this.count(),
items: this.items(),
filter: this.filter(),
});
});
// Conditional debugging
const DEBUG = signal(false);
effect(() => {
if (untracked(() => DEBUG())) {
console.log('Debug:', this.state());
}
});
```
+454
View File
@@ -0,0 +1,454 @@
---
name: angular-testing
description: Write unit and integration tests for Angular v20+ applications using Vitest or Jasmine with TestBed and modern testing patterns. Use for testing components with signals, OnPush change detection, services with inject(), and HTTP interactions. Triggers on test creation, testing signal-based components, mocking dependencies, or setting up test infrastructure. Don't use for E2E testing with Cypress or Playwright, or for testing non-Angular JavaScript/TypeScript code.
---
# Angular Testing
Test Angular v20+ applications with Vitest (recommended) or Jasmine, focusing on signal-based components and modern patterns.
## Vitest Setup (Angular v20+)
Angular v20+ has native Vitest support through the `@angular/build` package.
```bash
npm install -D vitest jsdom
```
Configure in angular.json:
```json
{
"projects": {
"your-app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build"
}
}
}
}
}
}
```
Run tests:
```bash
ng test # Run tests
ng test --watch # Watch mode
ng test --code-coverage # With coverage
```
For Vitest migration from Jasmine and advanced configuration, see [references/vitest-migration.md](references/vitest-migration.md).
## Basic Component Test
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Counter } from './counter.component';
describe('Counter', () => {
let component: Counter;
let fixture: ComponentFixture<Counter>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Counter], // Standalone component
}).compileComponents();
fixture = TestBed.createComponent(Counter);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should increment count', () => {
expect(component.count()).toBe(0);
component.increment();
expect(component.count()).toBe(1);
});
it('should display count in template', () => {
component.count.set(5);
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('.count');
expect(element.textContent).toContain('5');
});
});
```
## Testing Signals
### Direct Signal Testing
```typescript
import { signal, computed } from '@angular/core';
describe('Signal logic', () => {
it('should update computed when signal changes', () => {
const count = signal(0);
const doubled = computed(() => count() * 2);
expect(doubled()).toBe(0);
count.set(5);
expect(doubled()).toBe(10);
count.update((c) => c + 1);
expect(doubled()).toBe(12);
});
});
```
### Testing Component Signals
```typescript
@Component({
selector: 'app-todo-list',
template: `
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li>{{ todo.text }}</li>
}
</ul>
<p>{{ remaining() }} remaining</p>
`,
})
export class TodoList {
todos = signal<Todo[]>([]);
filter = signal<'all' | 'active' | 'done'>('all');
filteredTodos = computed(() => {
const todos = this.todos();
switch (this.filter()) {
case 'active':
return todos.filter((t) => !t.done);
case 'done':
return todos.filter((t) => t.done);
default:
return todos;
}
});
remaining = computed(() => this.todos().filter((t) => !t.done).length);
}
describe('TodoList', () => {
let component: TodoList;
let fixture: ComponentFixture<TodoList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TodoList],
}).compileComponents();
fixture = TestBed.createComponent(TodoList);
component = fixture.componentInstance;
});
it('should filter active todos', () => {
component.todos.set([
{ id: '1', text: 'Task 1', done: false },
{ id: '2', text: 'Task 2', done: true },
{ id: '3', text: 'Task 3', done: false },
]);
component.filter.set('active');
expect(component.filteredTodos().length).toBe(2);
expect(component.remaining()).toBe(2);
});
});
```
## Testing OnPush Components
OnPush components require explicit change detection:
```typescript
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span>{{ data().name }}</span>`,
})
export class OnPushCmpt {
data = input.required<{ name: string }>();
}
describe('OnPushCmpt', () => {
it('should update when input signal changes', () => {
const fixture = TestBed.createComponent(OnPushCmpt);
// Set input using setInput (for signal inputs)
fixture.componentRef.setInput('data', { name: 'Initial' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Initial');
// Update input
fixture.componentRef.setInput('data', { name: 'Updated' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Updated');
});
});
```
## Testing Services
### Basic Service Test
```typescript
@Injectable({ providedIn: 'root' })
export class CounterService {
private _count = signal(0);
readonly count = this._count.asReadonly();
increment() {
this._count.update((c) => c + 1);
}
reset() {
this._count.set(0);
}
}
describe('CounterService', () => {
let service: CounterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CounterService);
});
it('should increment count', () => {
expect(service.count()).toBe(0);
service.increment();
expect(service.count()).toBe(1);
});
});
```
### Service with HTTP
```typescript
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Verify no outstanding requests
});
it('should fetch user by id', () => {
const mockUser = { id: '1', name: 'Test User' };
service.getUser('1').subscribe((user) => {
expect(user).toEqual(mockUser);
});
const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
req.flush(mockUser);
});
});
```
## Mocking Dependencies
### Using Vitest Mocks
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('UserProfile', () => {
const mockUserService = {
getUser: vi.fn(),
updateUser: vi.fn(),
user: signal<User | null>(null),
};
beforeEach(async () => {
vi.clearAllMocks();
mockUserService.getUser.mockReturnValue(of({ id: '1', name: 'Test' }));
await TestBed.configureTestingModule({
imports: [UserProfile],
providers: [{ provide: UserService, useValue: mockUserService }],
}).compileComponents();
});
it('should call getUser on init', () => {
const fixture = TestBed.createComponent(UserProfile);
fixture.detectChanges();
expect(mockUserService.getUser).toHaveBeenCalledWith('1');
});
});
```
### Mock Signal-Based Service
```typescript
const mockAuth = {
user: signal<User | null>(null),
isAuthenticated: computed(() => mockAuth.user() !== null),
login: vi.fn(),
logout: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProtectedPage],
providers: [{ provide: AuthService, useValue: mockAuth }],
}).compileComponents();
});
it('should show content when authenticated', () => {
mockAuth.user.set({ id: '1', name: 'Test User' });
const fixture = TestBed.createComponent(ProtectedPage);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.protected-content')).toBeTruthy();
});
```
## Testing Inputs and Outputs
```typescript
@Component({
selector: 'app-item',
template: `<div (click)="select()">{{ item().name }}</div>`,
})
export class ItemCmpt {
item = input.required<Item>();
selected = output<Item>();
select() {
this.selected.emit(this.item());
}
}
describe('ItemCmpt', () => {
it('should emit selected event on click', () => {
const fixture = TestBed.createComponent(ItemCmpt);
const item: Item = { id: '1', name: 'Test Item' };
fixture.componentRef.setInput('item', item);
fixture.detectChanges();
let emittedItem: Item | undefined;
fixture.componentInstance.selected.subscribe((i) => (emittedItem = i));
fixture.nativeElement.querySelector('div').click();
expect(emittedItem).toEqual(item);
});
});
```
## Testing Async Operations
### Using fakeAsync
```typescript
import { fakeAsync, tick, flush } from '@angular/core/testing';
it('should debounce search', fakeAsync(() => {
const fixture = TestBed.createComponent(SearchCmpt);
fixture.detectChanges();
fixture.componentInstance.query.set('test');
tick(300); // Advance time for debounce
fixture.detectChanges();
expect(fixture.componentInstance.results().length).toBeGreaterThan(0);
flush(); // Flush remaining timers
}));
```
### Using waitForAsync
```typescript
import { waitForAsync } from '@angular/core/testing';
it('should load data', waitForAsync(() => {
const fixture = TestBed.createComponent(DataCmpt);
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(fixture.componentInstance.data()).toBeDefined();
});
}));
```
## Testing HTTP Resources
```typescript
@Component({
template: `
@if (userResource.isLoading()) {
<p>Loading...</p>
} @else if (userResource.hasValue()) {
<p>{{ userResource.value().name }}</p>
}
`,
})
export class UserCmpt {
userId = signal('1');
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
}
describe('UserCmpt', () => {
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
});
it('should display user name after loading', () => {
const fixture = TestBed.createComponent(UserCmpt);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Loading');
const req = httpMock.expectOne('/api/users/1');
req.flush({ id: '1', name: 'John Doe' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('John Doe');
});
});
```
For advanced testing patterns including component harnesses, router testing, form testing, and directive testing, see [references/testing-patterns.md](references/testing-patterns.md).
For Vitest migration from Jasmine, see [references/vitest-migration.md](references/vitest-migration.md).
@@ -0,0 +1,706 @@
# Angular Testing Patterns
## Table of Contents
- [Vitest Advanced Patterns](#vitest-advanced-patterns)
- [Component Harnesses](#component-harnesses)
- [Testing Router](#testing-router)
- [Testing Forms](#testing-forms)
- [Testing Directives](#testing-directives)
- [Testing Pipes](#testing-pipes)
- [E2E Testing Setup](#e2e-testing-setup)
## Vitest Advanced Patterns
### Snapshot Testing
```typescript
import { describe, it, expect } from 'vitest';
describe('UserCard', () => {
it('should match snapshot', () => {
const fixture = TestBed.createComponent(UserCard);
fixture.componentRef.setInput('user', {
id: '1',
name: 'John',
email: 'john@example.com',
});
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toMatchSnapshot();
});
});
```
### Parameterized Tests
```typescript
import { describe, it, expect } from 'vitest';
describe('Validator', () => {
it.each([
{ input: '', expected: false },
{ input: 'test', expected: false },
{ input: 'test@example.com', expected: true },
{ input: 'invalid@', expected: false },
])('should validate email "$input" as $expected', ({ input, expected }) => {
expect(isValidEmail(input)).toBe(expected);
});
});
```
### Testing with Fake Timers
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('Debounced Search', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should debounce search input', async () => {
const fixture = TestBed.createComponent(Search);
fixture.detectChanges();
fixture.componentInstance.query.set('test');
// Search not called yet
expect(fixture.componentInstance.results()).toEqual([]);
// Advance timers
vi.advanceTimersByTime(300);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.componentInstance.results().length).toBeGreaterThan(0);
});
});
```
### Module Mocking
```typescript
import { describe, it, expect, vi } from 'vitest';
// Mock entire module
vi.mock('./analytics.service', () => ({
Analytics: class {
track = vi.fn();
identify = vi.fn();
},
}));
describe('with mocked analytics', () => {
it('should track events', () => {
const fixture = TestBed.createComponent(Dashboard);
const analytics = TestBed.inject(Analytics);
fixture.detectChanges();
expect(analytics.track).toHaveBeenCalledWith('dashboard_viewed');
});
});
```
### Testing Async/Await
```typescript
import { describe, it, expect, vi } from 'vitest';
describe('User', () => {
it('should load user data', async () => {
const mockUser = { id: '1', name: 'Test' };
const httpMock = TestBed.inject(HttpTestingController);
const service = TestBed.inject(User);
const userPromise = service.loadUser('1');
httpMock.expectOne('/api/users/1').flush(mockUser);
const user = await userPromise;
expect(user).toEqual(mockUser);
});
});
```
### Coverage Configuration
```typescript
// vite.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'src/test-setup.ts', '**/*.spec.ts', '**/*.d.ts'],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
});
```
### Vitest UI Mode
```bash
# Run with UI
npx vitest --ui
# Open UI at specific port
npx vitest --ui --port 51204
```
### Concurrent Tests
```typescript
import { describe, it, expect } from 'vitest';
// Run tests in this describe block concurrently
describe.concurrent('API calls', () => {
it('should fetch users', async () => {
// ...
});
it('should fetch products', async () => {
// ...
});
it('should fetch orders', async () => {
// ...
});
});
```
### Test Fixtures
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
// Shared test fixtures
const createTestUser = (overrides = {}) => ({
id: '1',
name: 'Test User',
email: 'test@example.com',
...overrides,
});
const createTestProduct = (overrides = {}) => ({
id: '1',
name: 'Test Product',
price: 99.99,
...overrides,
});
describe('Order', () => {
it('should calculate total', () => {
const fixture = TestBed.createComponent(Order);
fixture.componentRef.setInput('user', createTestUser());
fixture.componentRef.setInput('products', [createTestProduct({ price: 10 }), createTestProduct({ id: '2', price: 20 })]);
fixture.detectChanges();
expect(fixture.componentInstance.total()).toBe(30);
});
});
```
## Component Harnesses
Use Angular CDK component harnesses for more maintainable tests:
### Creating a Harness
```typescript
import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';
export class CounterHarn extends ComponentHarness {
static hostSelector = 'app-counter';
// Locators
private getIncrementButton = this.locatorFor('button.increment');
private getDecrementButton = this.locatorFor('button.decrement');
private getCountDisplay = this.locatorFor('.count');
// Actions
async increment(): Promise<void> {
const button = await this.getIncrementButton();
await button.click();
}
async decrement(): Promise<void> {
const button = await this.getDecrementButton();
await button.click();
}
// Queries
async getCount(): Promise<number> {
const display = await this.getCountDisplay();
const text = await display.text();
return parseInt(text, 10);
}
// Filter factory
static with(options: { count?: number } = {}): HarnessPredicate<CounterHarn> {
return new HarnessPredicate(CounterHarn, options).addOption('count', options.count, async (harness, count) => {
return (await harness.getCount()) === count;
});
}
}
```
### Using Harnesses in Tests
```typescript
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
describe('Counter with Harness', () => {
let loader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Counter],
}).compileComponents();
const fixture = TestBed.createComponent(Counter);
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should increment count', async () => {
const counter = await loader.getHarness(CounterHarn);
expect(await counter.getCount()).toBe(0);
await counter.increment();
expect(await counter.getCount()).toBe(1);
await counter.increment();
expect(await counter.getCount()).toBe(2);
});
it('should find counter with specific count', async () => {
const counter = await loader.getHarness(CounterHarn);
await counter.increment();
await counter.increment();
// Find counter with count of 2
const counterWith2 = await loader.getHarness(CounterHarn.with({ count: 2 }));
expect(counterWith2).toBeTruthy();
});
});
```
## Testing Router
### RouterTestingHarness
```typescript
import { RouterTestingHarness } from '@angular/router/testing';
import { provideRouter } from '@angular/router';
describe('Router Navigation', () => {
let harness: RouterTestingHarness;
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: '', component: Home },
{ path: 'users/:id', component: UserCmpt },
]),
],
}).compileComponents();
harness = await RouterTestingHarness.create();
});
it('should navigate to user page', async () => {
const component = await harness.navigateByUrl('/users/123', UserCmpt);
expect(component.id()).toBe('123');
});
it('should display user name', async () => {
await harness.navigateByUrl('/users/123');
expect(harness.routeNativeElement?.textContent).toContain('User 123');
});
});
```
### Testing Guards
```typescript
describe('AuthGuard', () => {
let authService: jasmine.SpyObj<Auth>;
beforeEach(() => {
authService = jasmine.createSpyObj('Auth', ['isAuthenticated']);
TestBed.configureTestingModule({
providers: [
{ provide: Auth, useValue: authService },
provideRouter([
{ path: 'login', component: Login },
{
path: 'dashboard',
component: Dashboard,
canActivate: [authGuard],
},
]),
],
});
});
it('should allow access when authenticated', async () => {
authService.isAuthenticated.and.returnValue(true);
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/dashboard');
expect(harness.routeNativeElement?.textContent).toContain('Dashboard');
});
it('should redirect to login when not authenticated', async () => {
authService.isAuthenticated.and.returnValue(false);
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/dashboard');
expect(TestBed.inject(Router).url).toBe('/login');
});
});
```
## Testing Forms
### Testing Signal Forms
```typescript
import { form, FormField, required, email } from '@angular/forms/signals';
@Component({
imports: [FormField],
template: `
<form (submit)="onSubmit($event)">
<input [formField]="loginForm.email" />
<input
type="password"
[formField]="loginForm.password" />
<button
type="submit"
[disabled]="loginForm().invalid()">
Submit
</button>
</form>
`,
})
export class Login {
model = signal({ email: '', password: '' });
loginForm = form(this.model, (schemaPath) => {
required(schemaPath.email);
email(schemaPath.email);
required(schemaPath.password);
});
submitted = signal(false);
onSubmit(event: Event) {
event.preventDefault();
if (this.loginForm().valid()) {
this.submitted.set(true);
}
}
}
describe('Login', () => {
let fixture: ComponentFixture<Login>;
let component: Login;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Login],
}).compileComponents();
fixture = TestBed.createComponent(Login);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should be invalid when empty', () => {
expect(component.loginForm().invalid()).toBeTrue();
});
it('should be valid with correct data', () => {
component.model.set({
email: 'test@example.com',
password: 'password123',
});
expect(component.loginForm().valid()).toBeTrue();
});
it('should show email error for invalid email', () => {
component.loginForm.email().value.set('invalid');
fixture.detectChanges();
expect(component.loginForm.email().invalid()).toBeTrue();
expect(
component.loginForm
.email()
.errors()
.some((e) => e.kind === 'email'),
).toBeTrue();
});
it('should disable submit button when invalid', () => {
const button = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBeTrue();
});
});
```
### Testing Reactive Forms
```typescript
describe('ReactiveForm', () => {
it('should validate form', () => {
const fixture = TestBed.createComponent(ProfileForm);
const component = fixture.componentInstance;
expect(component.form.valid).toBeFalse();
component.form.patchValue({
name: 'John',
email: 'john@example.com',
});
expect(component.form.valid).toBeTrue();
});
it('should show validation errors', () => {
const fixture = TestBed.createComponent(ProfileForm);
fixture.detectChanges();
const emailControl = fixture.componentInstance.form.controls.email;
emailControl.setValue('invalid');
emailControl.markAsTouched();
fixture.detectChanges();
const errorElement = fixture.nativeElement.querySelector('.error');
expect(errorElement.textContent).toContain('Invalid email');
});
});
```
## Testing Directives
### Attribute Directive
```typescript
@Directive({
selector: '[appHighlight]',
host: {
'[style.backgroundColor]': 'color()',
},
})
export class Highlight {
color = input('yellow', { alias: 'appHighlight' });
}
describe('Highlight', () => {
@Component({
imports: [Highlight],
template: `<p appHighlight="lightblue">Test</p>`,
})
class Test {}
it('should apply background color', () => {
const fixture = TestBed.createComponent(Test);
fixture.detectChanges();
const p = fixture.nativeElement.querySelector('p');
expect(p.style.backgroundColor).toBe('lightblue');
});
});
```
### Structural Directive
```typescript
@Directive({
selector: '[appIf]',
})
export class If {
private templateRef = inject(TemplateRef);
private viewContainer = inject(ViewContainerRef);
condition = input.required<boolean>({ alias: 'appIf' });
constructor() {
effect(() => {
if (this.condition()) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
});
}
}
describe('If', () => {
@Component({
imports: [If],
template: `<p *appIf="show()">Visible</p>`,
})
class TestCmpt {
show = signal(false);
}
it('should show content when condition is true', () => {
const fixture = TestBed.createComponent(Test);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p')).toBeNull();
fixture.componentInstance.show.set(true);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p')).toBeTruthy();
});
});
```
## Testing Pipes
```typescript
@Pipe({ name: 'truncate' })
export class Truncate implements PipeTransform {
transform(value: string, length: number = 50): string {
if (value.length <= length) return value;
return value.substring(0, length) + '...';
}
}
describe('Truncate', () => {
let pipe: Truncate;
beforeEach(() => {
pipe = new Truncate();
});
it('should not truncate short strings', () => {
expect(pipe.transform('Hello', 10)).toBe('Hello');
});
it('should truncate long strings', () => {
expect(pipe.transform('Hello World', 5)).toBe('Hello...');
});
it('should use default length', () => {
const longString = 'a'.repeat(60);
const result = pipe.transform(longString);
expect(result.length).toBe(53); // 50 + '...'
});
});
```
## E2E Testing Setup
### Playwright Configuration
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
},
});
```
### E2E Test Example
```typescript
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'wrong@example.com');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toBeVisible();
await expect(page.locator('.error')).toContainText('Invalid credentials');
});
});
```
## Test Utilities
### Custom Test Helpers
```typescript
// test-utils.ts
export function setSignalInput<T>(fixture: ComponentFixture<any>, inputName: string, value: T): void {
fixture.componentRef.setInput(inputName, value);
fixture.detectChanges();
}
export async function waitForSignal<T>(signal: () => T, predicate: (value: T) => boolean, timeout = 5000): Promise<T> {
const start = Date.now();
while (Date.now() - start < timeout) {
const value = signal();
if (predicate(value)) return value;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error('Timeout waiting for signal');
}
// Usage
it('should load data', async () => {
const fixture = TestBed.createComponent(Data);
fixture.detectChanges();
await waitForSignal(
() => fixture.componentInstance.data(),
(data) => data !== undefined,
);
expect(fixture.componentInstance.data()).toBeDefined();
});
```
@@ -0,0 +1,159 @@
# Vitest Setup and Migration Guide
## Vitest vs Jasmine Comparison
| Feature | Vitest | Jasmine/Karma |
| ---------- | ----------------------- | --------------------- |
| Speed | Faster (native ESM) | Slower |
| Watch mode | Instant feedback | Slower rebuilds |
| Mocking | `vi.fn()`, `vi.mock()` | `jasmine.createSpy()` |
| Assertions | `expect()` (Chai-style) | `expect()` (Jasmine) |
| UI | Built-in UI mode | Karma browser |
| Config | `angular.json` | `karma.conf.js` |
## Migration from Jasmine to Vitest
### Spy Migration
```typescript
// Jasmine
const spy = jasmine.createSpy('callback');
spy.and.returnValue('value');
expect(spy).toHaveBeenCalledWith('arg');
// Vitest
const spy = vi.fn();
spy.mockReturnValue('value');
expect(spy).toHaveBeenCalledWith('arg');
```
### SpyOn Migration
```typescript
// Jasmine
spyOn(service, 'method').and.returnValue(of(data));
// Vitest
vi.spyOn(service, 'method').mockReturnValue(of(data));
```
### createSpyObj Migration
```typescript
// Jasmine
const mockService = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
mockService.getUser.and.returnValue(of({ id: '1', name: 'Test' }));
// Vitest
const mockService = {
getUser: vi.fn(),
updateUser: vi.fn(),
};
mockService.getUser.mockReturnValue(of({ id: '1', name: 'Test' }));
```
### Async Testing Migration
```typescript
// Jasmine - using done callback
it('should load data', (done) => {
service.loadData().subscribe((data) => {
expect(data).toBeDefined();
done();
});
});
// Vitest - using async/await
it('should load data', async () => {
const data = await firstValueFrom(service.loadData());
expect(data).toBeDefined();
});
```
### Clock/Timer Migration
```typescript
// Jasmine
jasmine.clock().install();
jasmine.clock().tick(1000);
jasmine.clock().uninstall();
// Vitest
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
vi.useRealTimers();
```
## Vitest Configuration Details
### Full angular.json Configuration
```json
{
"projects": {
"your-app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build"
}
}
}
}
}
}
```
### tsconfig.spec.json
```json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src/**/*.spec.ts"]
}
```
### Optional vite.config.ts
For advanced configuration, create a `vite.config.ts`:
```typescript
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'src/test-setup.ts', '**/*.spec.ts', '**/*.d.ts'],
},
},
});
```
## Running Vitest
```bash
# Run tests
ng test
# Watch mode
ng test --watch
# Coverage
ng test --code-coverage
# Run specific file pattern
ng test --include='**/user*.spec.ts'
# CI mode (single run)
ng test --watch=false
```
+352
View File
@@ -0,0 +1,352 @@
---
name: angular-tooling
description: Use Angular CLI and development tools effectively in Angular v20+ projects. Use for project setup, code generation, building, testing, and configuration. Triggers on creating new projects, generating components/services/modules, configuring builds, running tests, or optimizing production builds. Don't use for Nx workspace commands, custom Webpack configurations, or non-Angular CLI build systems like Vite standalone or esbuild direct usage.
---
# Angular Tooling
Use Angular CLI and development tools for efficient Angular v20+ development.
## Project Setup
### Create New Project
```bash
# Create new standalone project (default in v20+)
ng new my-app
# With specific options
ng new my-app --style=scss --routing --ssr=false
# Skip tests
ng new my-app --skip-tests
# Minimal setup
ng new my-app --minimal --inline-style --inline-template
```
### Project Structure
```
my-app/
├── src/
│ ├── app/
│ │ ├── app.component.ts
│ │ ├── app.config.ts
│ │ └── app.routes.ts
│ ├── index.html
│ ├── main.ts
│ └── styles.scss
├── public/ # Static assets
├── angular.json # CLI configuration
├── package.json
├── tsconfig.json
└── tsconfig.app.json
```
## Code Generation
### Components
```bash
# Generate component
ng generate component features/user-profile
ng g c features/user-profile # Short form
# With options
ng g c shared/button --inline-template --inline-style
ng g c features/dashboard --skip-tests
ng g c features/settings --change-detection=OnPush
# Flat (no folder)
ng g c shared/icon --flat
# Dry run (preview)
ng g c features/checkout --dry-run
```
### Services
```bash
# Generate service (providedIn: 'root' by default)
ng g service services/auth
ng g s services/user
# Skip tests
ng g s services/api --skip-tests
```
### Other Schematics
```bash
# Directive
ng g directive directives/highlight
ng g d directives/tooltip
# Pipe
ng g pipe pipes/truncate
ng g p pipes/date-format
# Guard (functional by default)
ng g guard guards/auth
# Interceptor (functional by default)
ng g interceptor interceptors/auth
# Interface
ng g interface models/user
# Enum
ng g enum models/status
# Class
ng g class models/product
```
### Generate with Path Alias
```bash
# Components in feature folders
ng g c @features/products/product-list
ng g c @shared/ui/button
```
## Development Server
```bash
# Start dev server
ng serve
ng s # Short form
# With options
ng serve --port 4201
ng serve --open # Open browser
ng serve --host 0.0.0.0 # Expose to network
# Production mode locally
ng serve --configuration=production
# With SSL
ng serve --ssl --ssl-key ./ssl/key.pem --ssl-cert ./ssl/cert.pem
```
## Building
### Development Build
```bash
ng build
```
### Production Build
```bash
ng build --configuration=production
ng build -c production # Short form
# With specific options
ng build -c production --source-map=false
ng build -c production --named-chunks
```
### Build Output
```
dist/my-app/
├── browser/
│ ├── index.html
│ ├── main-[hash].js
│ ├── polyfills-[hash].js
│ └── styles-[hash].css
└── server/ # If SSR enabled
└── main.js
```
## Testing
### Unit Tests
```bash
# Run tests
ng test
ng t # Short form
# Single run (CI)
ng test --watch=false --browsers=ChromeHeadless
# With coverage
ng test --code-coverage
# Specific file
ng test --include=**/user.service.spec.ts
```
### E2E Tests
```bash
# Run e2e (if configured)
ng e2e
```
## Linting
```bash
# Run linter
ng lint
# Fix auto-fixable issues
ng lint --fix
```
## Configuration
### angular.json Key Sections
```json
{
"projects": {
"my-app": {
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/my-app",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": ["{ \"glob\": \"**/*\", \"input\": \"public\" }"],
"styles": ["src/styles.scss"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
}
}
}
}
}
```
### Environment Configuration
```typescript
// src/environments/environment.ts
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api',
};
// src/environments/environment.prod.ts
export const environment = {
production: true,
apiUrl: 'https://api.example.com',
};
```
Configure in angular.json:
```json
{
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
}
}
}
```
## Adding Libraries
### Angular Libraries
```bash
# Add Angular Material
ng add @angular/material
# Add Angular PWA
ng add @angular/pwa
# Add Angular SSR
ng add @angular/ssr
# Add Angular Localize
ng add @angular/localize
```
### Third-Party Libraries
```bash
# Install and configure
npm install @ngrx/signals
# Some libraries have schematics
ng add @ngrx/store
```
## Update Angular
```bash
# Check for updates
ng update
# Update Angular core and CLI
ng update @angular/core @angular/cli
# Update all packages
ng update --all
# Force update (skip peer dependency checks)
ng update @angular/core @angular/cli --force
```
## Performance Analysis
```bash
# Build with stats
ng build -c production --stats-json
# Analyze bundle (install esbuild-visualizer)
npx esbuild-visualizer --metadata dist/my-app/browser/stats.json --open
```
## Caching
```bash
# Enable persistent build cache (default in v20+)
# Configured in angular.json:
{
"cli": {
"cache": {
"enabled": true,
"path": ".angular/cache",
"environment": "all"
}
}
}
# Clear cache
rm -rf .angular/cache
```
For advanced configuration, see [references/tooling-patterns.md](references/tooling-patterns.md).
@@ -0,0 +1,447 @@
# Angular Tooling Patterns
## Table of Contents
- [Custom Schematics](#custom-schematics)
- [Build Optimization](#build-optimization)
- [Multi-Project Workspace](#multi-project-workspace)
- [CI/CD Configuration](#cicd-configuration)
- [Path Aliases](#path-aliases)
- [Proxy Configuration](#proxy-configuration)
## Custom Schematics
### Generate Schematic Collection
```bash
# Install schematics CLI
npm install -g @angular-devkit/schematics-cli
# Create schematic collection
schematics blank --name=my-schematics
```
### Simple Component Schematic
```typescript
// src/my-component/index.ts
import { Rule, SchematicContext, Tree, apply, url, template, move, mergeWith } from '@angular-devkit/schematics';
import { strings } from '@angular-devkit/core';
export function myComponent(options: { name: string; path: string }): Rule {
return (tree: Tree, context: SchematicContext) => {
const templateSource = apply(url('./files'), [
template({
...options,
...strings,
}),
move(options.path),
]);
return mergeWith(templateSource)(tree, context);
};
}
```
### Use Custom Schematics
```bash
# Link locally
npm link ./my-schematics
# Use
ng generate my-schematics:my-component --name=test --path=src/app
```
## Build Optimization
### Budget Configuration
```json
{
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
},
{
"type": "anyScript",
"maximumWarning": "100kB",
"maximumError": "200kB"
}
]
}
```
### Differential Loading
Automatic in v20+ - builds for modern browsers by default.
```json
// .browserslistrc
last 2 Chrome versions
last 2 Firefox versions
last 2 Safari versions
last 2 Edge versions
```
### Code Splitting
```typescript
// Lazy load routes for automatic code splitting
export const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then((m) => m.adminRoutes),
},
{
path: 'reports',
loadComponent: () => import('./reports/reports.component').then((m) => m.Reports),
},
];
```
### Tree Shaking
Ensure proper imports for tree shaking:
```typescript
// Good - tree shakeable
import { map, filter } from 'rxjs';
// Avoid - imports entire library
import * as rxjs from 'rxjs';
```
### Preload Strategy
```typescript
// app.config.ts
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withPreloading(PreloadAllModules))],
};
```
## Multi-Project Workspace
### Create Workspace
```bash
# Create empty workspace
ng new my-workspace --create-application=false
cd my-workspace
# Add applications
ng generate application main-app
ng generate application admin-app
# Add library
ng generate library shared-ui
ng generate library data-access
```
### Workspace Structure
```
my-workspace/
├── projects/
│ ├── main-app/
│ │ └── src/
│ ├── admin-app/
│ │ └── src/
│ ├── shared-ui/
│ │ └── src/
│ └── data-access/
│ └── src/
├── angular.json
└── package.json
```
### Build Specific Project
```bash
ng build main-app
ng build shared-ui
ng serve admin-app
```
### Library Configuration
```json
// projects/shared-ui/ng-package.json
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/shared-ui",
"lib": {
"entryFile": "src/public-api.ts"
}
}
```
### Using Library in App
```typescript
// After building library: ng build shared-ui
import { Button } from 'shared-ui';
@Component({
imports: [Button],
template: `<lib-button>Click</lib-button>`,
})
export class App {}
```
## CI/CD Configuration
### GitHub Actions
```yaml
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm run test -- --watch=false --browsers=ChromeHeadless --code-coverage
- name: Build
run: npm run build -- -c production
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
```
### GitLab CI
```yaml
# .gitlab-ci.yml
image: node:20
cache:
paths:
- node_modules/
- .angular/cache/
stages:
- install
- test
- build
install:
stage: install
script:
- npm ci
test:
stage: test
script:
- npm run lint
- npm run test -- --watch=false --browsers=ChromeHeadless
build:
stage: build
script:
- npm run build -- -c production
artifacts:
paths:
- dist/
```
## Path Aliases
### Configure tsconfig.json
```json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@app/*": ["src/app/*"],
"@env/*": ["src/environments/*"],
"@shared/*": ["src/app/shared/*"],
"@features/*": ["src/app/features/*"],
"@core/*": ["src/app/core/*"]
}
}
}
```
### Usage
```typescript
// Instead of relative imports
import { User } from '../../../core/services/user.service';
// Use path alias
import { User } from '@core/services/user.service';
```
## Proxy Configuration
### Development Proxy
```json
// proxy.conf.json
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
},
"/auth": {
"target": "http://localhost:4000",
"secure": false,
"pathRewrite": {
"^/auth": ""
}
}
}
```
### Configure in angular.json
```json
{
"serve": {
"options": {
"proxyConfig": "proxy.conf.json"
}
}
}
```
### Or via CLI
```bash
ng serve --proxy-config proxy.conf.json
```
## Custom Builders
### Using esbuild (Default in v20+)
```json
{
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"browser": "src/main.ts"
}
}
}
}
```
### SSR Configuration
```bash
# Add SSR
ng add @angular/ssr
```
```json
{
"architect": {
"build": {
"options": {
"server": "src/main.server.ts",
"prerender": true,
"ssr": {
"entry": "server.ts"
}
}
}
}
}
```
## Debugging
### Source Maps
```json
{
"configurations": {
"development": {
"sourceMap": true
},
"production": {
"sourceMap": {
"scripts": true,
"styles": false,
"hidden": true,
"vendor": false
}
}
}
}
```
### Verbose Logging
```bash
ng build --verbose
ng serve --verbose
```
### Debug Tests
```bash
# Run tests with debugging
ng test --browsers=Chrome
# In Chrome DevTools, open Sources tab and set breakpoints
```
## Package Scripts
```json
{
"scripts": {
"start": "ng serve",
"build": "ng build",
"build:prod": "ng build -c production",
"test": "ng test",
"test:ci": "ng test --watch=false --browsers=ChromeHeadless --code-coverage",
"lint": "ng lint",
"lint:fix": "ng lint --fix",
"analyze": "ng build -c production --stats-json && npx esbuild-visualizer --metadata dist/my-app/browser/stats.json --open",
"update": "ng update"
}
}
```
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+45
View File
@@ -0,0 +1,45 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-best-practices-ngrx
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-component
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-di
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-directives
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-forms
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-http
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-routing
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-signals
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-testing
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/angular-tooling
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/frontend-design
+25 -2
View File
@@ -1,6 +1,7 @@
kind: pipeline kind: pipeline
type: kubernetes type: kubernetes
name: pr-check name: pr-check
trigger: trigger:
event: event:
- pull_request - pull_request
@@ -16,6 +17,7 @@ steps:
kind: pipeline kind: pipeline
type: kubernetes type: kubernetes
name: deploy name: deploy
trigger: trigger:
branch: branch:
- main - main
@@ -35,9 +37,30 @@ steps:
registry: gitea.mnky-code.de registry: gitea.mnky-code.de
repo: gitea.mnky-code.de/mnky/ngrx-playground repo: gitea.mnky-code.de/mnky/ngrx-playground
tags: tags:
- latest - ${DRONE_COMMIT_SHA}
- ${DRONE_COMMIT_SHA:0:8}
username: username:
from_secret: gitea_username from_secret: gitea_username
password: password:
from_secret: gitea_password from_secret: gitea_password
- name: update-manifests
image: alpine:3.20
environment:
GITEA_USERNAME:
from_secret: gitea_git_username
GITEA_TOKEN:
from_secret: gitea_git_token
commands:
- apk add --no-cache git sed
- test -n "$GITEA_USERNAME"
- test -n "$GITEA_TOKEN"
- 'printf "machine gitea.mnky-code.de\nlogin %s\npassword %s\n" "$GITEA_USERNAME" "$GITEA_TOKEN" > ~/.netrc'
- chmod 600 ~/.netrc
- git clone https://gitea.mnky-code.de/mnky/k8s-manifests.git
- cd k8s-manifests
- 'sed -i "s|image: gitea.mnky-code.de/mnky/ngrx-playground:.*|image: gitea.mnky-code.de/mnky/ngrx-playground:${DRONE_COMMIT_SHA}|" ngrx-playground/deployment.yaml'
- git config user.name "drone"
- git config user.email "drone@mnky-code.de"
- git add ngrx-playground/deployment.yaml
- 'git commit -m "Deploy ngrx-playground ${DRONE_COMMIT_SHA}" || exit 0'
- git push origin HEAD:main
+2
View File
@@ -2,3 +2,5 @@
.idea .idea
*.DS_Store *.DS_Store
.angular
dist
+22
View File
@@ -0,0 +1,22 @@
{
"arrowParens": "always",
"bracketSameLine": true,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 140,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleAttributePerLine": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false,
"plugins": ["prettier-plugin-organize-attributes"],
"attributeGroups": ["$ANGULAR_STRUCTURAL_DIRECTIVE", "$DEFAULT", "$ANGULAR_INPUT", "$ANGULAR_TWO_WAY_BINDING", "$ANGULAR_OUTPUT"]
}
+1
View File
@@ -9,5 +9,6 @@ RUN npm run build
# Runtime Stage # Runtime Stage
FROM nginx:alpine FROM nginx:alpine
COPY --from=builder /app/dist/*/browser /usr/share/nginx/html COPY --from=builder /app/dist/*/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+1 -1
View File
@@ -32,7 +32,7 @@ Open [http://localhost:4200](http://localhost:4200) in your browser. The app rel
## Scripts ## Scripts
| Command | Description | | Command | Description |
|---|---| | ------------------------------ | -------------------------- |
| `ng serve` | Start development server | | `ng serve` | Start development server |
| `ng build` | Build for production | | `ng build` | Build for production |
| `ng test` | Run unit tests with Vitest | | `ng test` | Run unit tests with Vitest |
+9 -4
View File
@@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm" "packageManager": "npm",
"schematicCollections": ["angular-eslint"]
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
@@ -24,9 +25,7 @@
"input": "public" "input": "public"
} }
], ],
"styles": [ "styles": ["src/styles.css"]
"src/styles.css"
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -66,6 +65,12 @@
}, },
"test": { "test": {
"builder": "@angular/build:unit-test" "builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
} }
} }
} }
+36
View File
@@ -0,0 +1,36 @@
// @ts-check
const eslint = require('@eslint/js');
const { defineConfig } = require('eslint/config');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
module.exports = defineConfig([
{
files: ['**/*.ts'],
extends: [eslint.configs.recommended, tseslint.configs.recommended, tseslint.configs.stylistic, angular.configs.tsRecommended],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
rules: {},
},
]);
+11
View File
@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+1824 -28
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -6,7 +6,8 @@
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"lint": "ng lint"
}, },
"private": true, "private": true,
"packageManager": "npm@11.6.2", "packageManager": "npm@11.6.2",
@@ -17,16 +18,26 @@
"@angular/forms": "^21.2.0", "@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@ngrx/eslint-plugin": "^17.2.0",
"@ngrx/signals": "^21.0.1",
"@ngrx/store": "^21.0.1",
"@ngrx/store-devtools": "^21.0.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-architects/ngrx-toolkit": "^21.0.1",
"@angular/build": "^21.2.1", "@angular/build": "^21.2.1",
"@angular/cli": "^21.2.1", "@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.0", "@angular/compiler-cli": "^21.2.0",
"@eslint/js": "^10.0.1",
"angular-eslint": "21.3.0",
"eslint": "^10.0.2",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"prettier": "^3.8.1", "prettier": "3.8.1",
"prettier-plugin-organize-attributes": "^1.0.0",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "8.56.1",
"vitest": "^4.0.8" "vitest": "^4.0.8"
} }
} }
+60
View File
@@ -0,0 +1,60 @@
{
"version": 1,
"skills": {
"angular-best-practices-ngrx": {
"source": "alfredoperez/angular-best-practices",
"sourceType": "github",
"computedHash": "3ced41dfe6d6738c27627675a4a8f48228badcd6470654173cc235d04d812a6f"
},
"angular-component": {
"source": "analogjs/angular-skills",
"sourceType": "github",
"computedHash": "2789fdf11e2a70832ce2e6e79954018325a010ebdd45c5d4b1e7bfdf44679b7c"
},
"angular-di": {
"source": "analogjs/angular-skills",
"sourceType": "github",
"computedHash": "4d3dd9ecceec5642eea118aede824501bdec537b6b8735564a68787ec9617b75"
},
"angular-directives": {
"source": "analogjs/angular-skills",
"sourceType": "github",
"computedHash": "b8ce636215ec0792839fe57c3632b475957bed1ebc404e9a1c54222e0e506bfa"
},
"angular-forms": {
"source": "analogjs/angular-skills",
"sourceType": "github",
"computedHash": "a2e28f7c7c1b9b13f0a8212f403515886f9d23bf13732a355b1f5d01f5cc9d69"
},
"angular-http": {
"source": "analogjs/angular-skills",
"sourceType": "github",
"computedHash": "ad8821177929ea09eb94e7fc386d842c76c7f39b6c4d7aa3176ba3596dbabecc"
},
"angular-routing": {
"source": "analogjs/angular-skills",
"sourceType": "github",
"computedHash": "e54b8803da246042525e67effe7ea2a9dc26d5a50e857125793f579c7027aef3"
},
"angular-signals": {
"source": "analogjs/angular-skills",
"sourceType": "github",
"computedHash": "ddedef1232295f200b966778bc36d16fc07d2113ad1dbc73d75d14eb544953bc"
},
"angular-testing": {
"source": "analogjs/angular-skills",
"sourceType": "github",
"computedHash": "66adf01ccb30305e179ce41ff76ded6b60dbb5909bd889cc350cdd13b6933130"
},
"angular-tooling": {
"source": "analogjs/angular-skills",
"sourceType": "github",
"computedHash": "2a67063279e8ac1b5cf3d9c4e9ed4652508564640c1fc59642857d4f6d20db11"
},
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
}
}
}
+1 -5
View File
@@ -1,11 +1,7 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
provideBrowserGlobalErrorListeners(),
provideRouter(routes)
]
}; };
+151
View File
@@ -0,0 +1,151 @@
:host {
--page-ink: #13262f;
--page-muted: #5f7278;
--page-line: rgba(19, 38, 47, 0.1);
--page-panel: rgba(255, 255, 255, 0.74);
--page-panel-strong: rgba(255, 255, 255, 0.9);
--page-accent: #0f5d66;
--page-accent-strong: #0c3d49;
--page-glow: #d7f0eb;
display: block;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(255, 204, 112, 0.16), transparent 24%),
radial-gradient(circle at 85% 18%, rgba(76, 161, 175, 0.18), transparent 22%),
linear-gradient(180deg, #f4efe6, #edf2f0 38%, #f8fbfb);
color: var(--page-ink);
}
.shell {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 1.75rem 0 3.5rem;
}
.shell-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1.25rem;
align-items: end;
margin-bottom: 1.8rem;
padding: 1.35rem 1.5rem;
border: 1px solid var(--page-line);
border-radius: 1.6rem;
background: linear-gradient(180deg, var(--page-panel-strong), var(--page-panel));
box-shadow:
0 24px 60px rgba(29, 52, 58, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
backdrop-filter: blur(18px);
}
.brand-block {
display: grid;
gap: 0.55rem;
}
.brand-kicker {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--page-muted);
}
.brand-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.9rem;
}
.shell-header h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 0.98;
}
.version-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.25rem;
padding: 0.35rem 0.8rem;
border-radius: 999px;
background: linear-gradient(135deg, #d9edea, #f6d9b6);
color: var(--page-accent-strong);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.brand-copy {
max-width: 40rem;
margin: 0;
color: var(--page-muted);
font-size: 0.98rem;
line-height: 1.55;
}
nav a {
display: inline-flex;
align-items: center;
padding: 0.7rem 1rem;
border: 1px solid rgba(15, 93, 102, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.76);
color: inherit;
text-decoration: none;
font-weight: 600;
box-shadow: 0 10px 24px rgba(20, 38, 44, 0.06);
transition:
transform 180ms ease,
background-color 180ms ease,
color 180ms ease,
border-color 180ms ease;
}
nav a:hover {
transform: translateY(-1px);
border-color: rgba(15, 93, 102, 0.28);
}
nav a.active {
background: var(--page-accent-strong);
color: #ffffff;
}
main {
position: relative;
}
main::before {
content: '';
position: absolute;
inset: -1rem auto auto -1rem;
width: 10rem;
height: 10rem;
border-radius: 50%;
background: radial-gradient(circle, var(--page-glow), transparent 70%);
filter: blur(14px);
opacity: 0.65;
pointer-events: none;
}
@media (max-width: 720px) {
.shell {
width: min(72rem, calc(100% - 1rem));
padding-top: 1rem;
}
.shell-header {
padding: 1.1rem 1rem;
border-radius: 1.2rem;
}
.brand-copy {
font-size: 0.92rem;
}
}
+23 -340
View File
@@ -1,344 +1,27 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> <div class="shell">
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * --> <header class="shell-header">
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * --> <div class="brand-block">
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * --> <p class="brand-kicker">Angular + NgRx Signals</p>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> <div class="brand-row">
<!-- * * * * * * * * * Delete the template below * * * * * * * * * --> <h1>NGRX Playground</h1>
<!-- * * * * * * * to get started with your project! * * * * * * * --> <span class="version-pill">Signal Store</span>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: block;
height: 100dvh;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
white-space: nowrap;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--electric-violet);
}
.pill-group .pill:nth-child(6n + 3) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5),
.pill-group .pill:nth-child(6n + 6) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title() }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div> </div>
<div class="divider" role="separator" aria-label="Divider"></div> <p class="brand-copy">
<div class="right-side"> Feature-first state architecture with persistence, derived state,
<div class="pill-group"> and production-ready debugging.
@for (item of [ </p>
{ title: 'Explore the Docs', link: 'https://angular.dev' }, </div>
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, <nav aria-label="Primary">
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a <a
class="pill" routerLink="/tasks"
[href]="item.link" routerLinkActive="active"
target="_blank" >Tasks</a
rel="noopener"
> >
<span>{{ item.title }}</span> </nav>
<svg </header>
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://x.com/angular"
aria-label="X"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="X"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> <main>
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * --> <router-outlet />
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * --> </main>
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * --> </div>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />
+15 -1
View File
@@ -1,3 +1,17 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
export const routes: Routes = []; export const routes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'tasks',
},
{
path: 'tasks',
loadComponent: () => import('./features/tasks/feature/tasks-page.component').then((module) => module.TasksPageComponent),
},
{
path: '**',
redirectTo: 'tasks',
},
];
+4 -1
View File
@@ -1,10 +1,12 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { appConfig } from './app.config';
import { App } from './app'; import { App } from './app';
describe('App', () => { describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: appConfig.providers,
}).compileComponents(); }).compileComponents();
}); });
@@ -17,7 +19,8 @@ describe('App', () => {
it('should render title', async () => { it('should render title', async () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
await fixture.whenStable(); await fixture.whenStable();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, ngrx-playground'); expect(compiled.querySelector('h1')?.textContent).toContain('NGRX Playground');
}); });
}); });
+6 -7
View File
@@ -1,12 +1,11 @@
import { Component, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css' styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class App { export class App {}
protected readonly title = signal('ngrx-playground');
}
@@ -0,0 +1,10 @@
export type TaskPriority = 'low' | 'medium' | 'high';
export type TaskFilter = 'all' | 'active' | 'completed';
export interface Task {
id: string;
title: string;
completed: boolean;
priority: TaskPriority;
updatedAt: string;
}
@@ -0,0 +1,82 @@
import { Injectable } from '@angular/core';
import { Observable, delay, of, throwError } from 'rxjs';
import { Task, TaskPriority } from './task.model';
const NETWORK_DELAY_MS = 250;
@Injectable({ providedIn: 'root' })
export class TasksApiService {
private tasks: Task[] = [
{
id: 'task-1',
title: 'Review signal store conventions',
completed: true,
priority: 'high',
updatedAt: '2026-03-08T08:00:00.000Z',
},
{
id: 'task-2',
title: 'Document persistence boundaries',
completed: false,
priority: 'medium',
updatedAt: '2026-03-08T08:02:00.000Z',
},
{
id: 'task-3',
title: 'Prepare lazy-loaded feature shell',
completed: false,
priority: 'low',
updatedAt: '2026-03-08T08:05:00.000Z',
},
];
getTasks(): Observable<Task[]> {
return of(this.tasks).pipe(delay(NETWORK_DELAY_MS));
}
createTask(title: string, priority: TaskPriority): Observable<Task> {
const trimmedTitle = title.trim();
if (!trimmedTitle) {
return throwError(() => new Error('Task title cannot be empty.'));
}
const task: Task = {
id: crypto.randomUUID(),
title: trimmedTitle,
completed: false,
priority,
updatedAt: new Date().toISOString(),
};
this.tasks = [task, ...this.tasks];
return of(task).pipe(delay(NETWORK_DELAY_MS));
}
toggleTask(taskId: string): Observable<Task> {
const task = this.tasks.find((item) => item.id === taskId);
if (!task) {
return throwError(() => new Error('Task not found.'));
}
const updatedTask: Task = {
...task,
completed: !task.completed,
updatedAt: new Date().toISOString(),
};
this.tasks = this.tasks.map((item) => (item.id === taskId ? updatedTask : item));
return of(updatedTask).pipe(delay(NETWORK_DELAY_MS));
}
archiveCompleted(): Observable<string[]> {
const archivedIds = this.tasks.filter((task) => task.completed).map((task) => task.id);
this.tasks = this.tasks.filter((task) => !task.completed);
return of(archivedIds).pipe(delay(NETWORK_DELAY_MS));
}
}
@@ -0,0 +1,221 @@
import { computed, inject } from '@angular/core';
import {
addEntity,
removeEntities,
setAllEntities,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import {
signalStore,
withComputed,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import {
setError,
setLoaded,
setLoading,
updateState,
withCallState,
withDevtools,
withLocalStorage,
withStorageSync,
} from '@angular-architects/ngrx-toolkit';
import { EMPTY, pipe } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators';
import { Task, TaskFilter, TaskPriority } from './task.model';
import { TasksApiService } from './tasks-api.service';
interface TasksState {
filter: TaskFilter;
draftPriority: TaskPriority;
searchTerm: string;
lastSyncedAt: string | null;
}
const initialState: TasksState = {
filter: 'all',
draftPriority: 'medium',
searchTerm: '',
lastSyncedAt: null,
};
function matchesFilter(task: Task, filter: TaskFilter): boolean {
switch (filter) {
case 'active':
return !task.completed;
case 'completed':
return task.completed;
default:
return true;
}
}
export const TasksStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities<Task>(),
withCallState(),
withDevtools('tasks-store'),
withStorageSync(
{
key: 'ngrx-playground.tasks',
select: (state) => ({
ids: state.ids,
entityMap: state.entityMap,
filter: state.filter,
draftPriority: state.draftPriority,
searchTerm: state.searchTerm,
lastSyncedAt: state.lastSyncedAt,
}),
},
withLocalStorage(),
),
withComputed((store) => ({
filteredTasks: computed(() => {
const normalizedSearch = store.searchTerm().trim().toLowerCase();
return store.entities().filter((task) => {
const matchesText = normalizedSearch.length === 0 || task.title.toLowerCase().includes(normalizedSearch);
return matchesFilter(task, store.filter()) && matchesText;
});
}),
totalCount: computed(() => store.entities().length),
activeCount: computed(() => store.entities().filter((task) => !task.completed).length),
completedCount: computed(() => store.entities().filter((task) => task.completed).length),
hasCompletedTasks: computed(() => store.entities().some((task) => task.completed)),
emptyStateMessage: computed(() => {
if (store.loading()) {
return 'Loading tasks...';
}
if (store.error()) {
return 'The task list could not be refreshed.';
}
if (store.entities().length === 0) {
return 'No tasks saved yet. Create the first one.';
}
return 'No tasks match the current filters.';
}),
lastSyncedLabel: computed(() => {
const value = store.lastSyncedAt();
return value ? new Date(value).toLocaleTimeString('en-US') : 'Never';
}),
})),
methodsStore(),
withHooks({
onInit(store) {
if (store.entities().length === 0) {
store.loadTasks();
}
},
}),
);
function methodsStore() {
return withMethods((store, api = inject(TasksApiService)) => ({
loadTasks: rxMethod<void>(
pipe(
tap(() => updateState(store, 'tasks load started', setLoading())),
switchMap(() =>
api.getTasks().pipe(
tap((tasks) =>
updateState(
store,
'tasks load succeeded',
setAllEntities(tasks),
{ lastSyncedAt: new Date().toISOString() },
setLoaded(),
),
),
catchError((error: unknown) => {
updateState(store, 'tasks load failed', setError(error));
return EMPTY;
}),
),
),
),
),
createTask: rxMethod<{ title: string; priority: TaskPriority }>(
pipe(
tap(() => updateState(store, 'task create started', setLoading())),
switchMap(({ title, priority }) =>
api.createTask(title, priority).pipe(
tap((task) =>
updateState(store, 'task create succeeded', addEntity(task), { lastSyncedAt: task.updatedAt }, setLoaded()),
),
catchError((error: unknown) => {
updateState(store, 'task create failed', setError(error));
return EMPTY;
}),
),
),
),
),
toggleTask: rxMethod<string>(
pipe(
tap(() => updateState(store, 'task toggle started', setLoading())),
switchMap((taskId) =>
api.toggleTask(taskId).pipe(
tap((task) =>
updateState(
store,
'task toggle succeeded',
updateEntity({ id: task.id, changes: task }),
{ lastSyncedAt: task.updatedAt },
setLoaded(),
),
),
catchError((error: unknown) => {
updateState(store, 'task toggle failed', setError(error));
return EMPTY;
}),
),
),
),
),
archiveCompleted: rxMethod<void>(
pipe(
tap(() => updateState(store, 'completed tasks archive started', setLoading())),
switchMap(() =>
api.archiveCompleted().pipe(
tap((archivedIds) =>
updateState(
store,
'completed tasks archive succeeded',
removeEntities(archivedIds),
{ lastSyncedAt: new Date().toISOString() },
setLoaded(),
),
),
catchError((error: unknown) => {
updateState(store, 'completed tasks archive failed', setError(error));
return EMPTY;
}),
),
),
),
),
setFilter(filter: TaskFilter): void {
updateState(store, 'task filter changed', { filter });
},
setDraftPriority(priority: TaskPriority): void {
updateState(store, 'task priority draft changed', { draftPriority: priority });
},
setSearchTerm(searchTerm: string): void {
updateState(store, 'task search changed', { searchTerm });
},
}));
}
@@ -0,0 +1,440 @@
:host {
display: block;
}
.tasks-page {
display: grid;
gap: 1.5rem;
}
.hero {
display: grid;
gap: 1.75rem;
padding: 2rem;
border: 1px solid rgba(214, 230, 230, 0.32);
border-radius: 1.8rem;
background:
linear-gradient(120deg, rgba(255, 255, 255, 0.08), transparent 32%),
radial-gradient(circle at top right, rgba(255, 196, 93, 0.34), transparent 28%),
linear-gradient(135deg, #102d3a, #11495b 56%, #2f7a72);
color: #f6fbff;
box-shadow:
0 34px 70px rgba(19, 43, 49, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.hero-copy {
display: grid;
gap: 0.8rem;
}
.hero-badges {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.eyebrow {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(246, 251, 255, 0.8);
}
.hero-chip {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.25rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.hero h1,
.hero .lead {
margin: 0;
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3.4rem);
line-height: 0.95;
}
.lead {
max-width: 44rem;
color: rgba(246, 251, 255, 0.84);
font-size: 1rem;
line-height: 1.55;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.9rem;
margin: 0;
}
.summary-grid div {
padding: 1rem 1.1rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.15rem;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
}
.summary-grid dt {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(246, 251, 255, 0.74);
}
.summary-grid dd {
margin: 0.35rem 0 0;
font-size: 1.7rem;
font-weight: 700;
}
.workspace {
display: grid;
gap: 1.1rem;
}
.composer,
.toolbar,
.task-card,
.empty-state {
padding: 1.2rem;
border: 1px solid rgba(20, 54, 60, 0.08);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
box-shadow:
0 18px 40px rgba(18, 43, 48, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
backdrop-filter: blur(14px);
}
.composer,
.toolbar {
display: grid;
gap: 1.1rem;
}
.section-heading {
display: flex;
flex-wrap: wrap;
gap: 0.7rem 1rem;
justify-content: space-between;
align-items: end;
}
.section-heading h2,
.section-note,
.section-kicker {
margin: 0;
}
.section-kicker {
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #5e787d;
}
.section-heading h2 {
font-size: 1.2rem;
}
.section-note {
max-width: 26rem;
color: #678086;
font-size: 0.92rem;
}
.composer-grid,
.toolbar-grid {
display: grid;
gap: 1rem;
}
.composer label,
.search {
display: grid;
gap: 0.45rem;
min-width: 0;
}
.composer span,
.search span {
font-size: 0.9rem;
font-weight: 600;
color: #35535b;
}
input,
select,
button {
font: inherit;
}
input,
select {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.86rem 0.95rem;
border: 1px solid rgba(35, 71, 78, 0.18);
border-radius: 0.9rem;
background: rgba(249, 251, 251, 0.92);
color: #102932;
transition:
border-color 180ms ease,
box-shadow 180ms ease,
background-color 180ms ease;
}
input:focus,
select:focus {
outline: none;
border-color: rgba(17, 73, 91, 0.46);
box-shadow: 0 0 0 4px rgba(17, 73, 91, 0.12);
background: #ffffff;
}
button {
padding: 0.78rem 1.08rem;
border: 0;
border-radius: 999px;
background: #153f49;
color: #ffffff;
cursor: pointer;
font-weight: 700;
transition:
transform 180ms ease,
box-shadow 180ms ease,
opacity 180ms ease,
background-color 180ms ease;
}
button:hover:not([disabled]) {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(21, 63, 73, 0.2);
}
button[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.primary-action {
background: linear-gradient(135deg, #153f49, #23666b);
}
.ghost-action {
background: rgba(20, 54, 60, 0.08);
color: #153f49;
}
.filters,
.toolbar-actions,
.task-main,
.task-status,
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.filters button {
background: rgba(20, 54, 60, 0.08);
color: #15353a;
}
.filters button.active {
background: #153f49;
color: #ffffff;
}
.toolbar-divider {
width: 1px;
min-height: 3.1rem;
background: linear-gradient(
180deg,
rgba(19, 63, 73, 0),
rgba(19, 63, 73, 0.18),
rgba(19, 63, 73, 0)
);
}
.toolbar-actions {
padding-left: 0.5rem;
}
.task-list {
display: grid;
gap: 0.9rem;
padding: 0;
margin: 0;
list-style: none;
}
.task-card.done {
background: rgba(238, 247, 244, 0.92);
}
.task-main {
justify-content: space-between;
align-items: center;
}
.task-status {
flex: 0 0 auto;
}
.task-copy {
flex: 1 1 18rem;
display: grid;
gap: 0.45rem;
}
.task-main h2,
.feedback {
margin: 0;
}
.task-main h2 {
font-size: 1.02rem;
line-height: 1.25;
}
.task-meta {
gap: 0.55rem;
color: #5e767c;
font-size: 0.9rem;
}
.meta-pill {
display: inline-flex;
align-items: center;
min-height: 1.75rem;
padding: 0.2rem 0.65rem;
border-radius: 999px;
background: rgba(20, 54, 60, 0.08);
color: #1b4a51;
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.priority-mark {
width: 0.8rem;
height: 0.8rem;
border-radius: 50%;
background: #e2b34f;
box-shadow: 0 0 0 5px rgba(226, 179, 79, 0.12);
}
.priority-mark.high {
background: #cf5b35;
box-shadow: 0 0 0 5px rgba(207, 91, 53, 0.12);
}
.priority-mark.medium {
background: #d7a847;
box-shadow: 0 0 0 5px rgba(215, 168, 71, 0.12);
}
.priority-mark.low {
background: #4f8b74;
box-shadow: 0 0 0 5px rgba(79, 139, 116, 0.12);
}
.toggle {
min-width: 7.5rem;
background: rgba(217, 93, 57, 0.12);
color: #a94a2b;
box-shadow: none;
}
.task-card.done .toggle {
background: rgba(74, 130, 85, 0.14);
color: #406c45;
}
.feedback {
padding: 0.95rem 1rem;
border-radius: 1rem;
background: rgba(233, 243, 245, 0.86);
color: #24434c;
}
.feedback.error {
background: rgba(255, 233, 228, 0.9);
color: #8d2d10;
}
.empty-state {
color: #4a6368;
text-align: center;
padding-block: 2rem;
}
@media (min-width: 720px) {
.composer-grid {
grid-template-columns: minmax(0, 1fr) minmax(10rem, 12rem) auto;
align-items: end;
}
.toolbar-grid {
grid-template-columns: minmax(16rem, 1.3fr) auto 1px auto;
align-items: end;
}
}
@media (max-width: 719px) {
.hero,
.composer,
.toolbar,
.task-card,
.empty-state {
padding: 1rem;
border-radius: 1.15rem;
}
.task-main {
align-items: flex-start;
}
.toolbar-actions {
width: 100%;
padding-left: 0;
padding-top: 0.35rem;
border-top: 1px solid rgba(19, 63, 73, 0.08);
}
.toolbar-actions button {
flex: 1 1 10rem;
}
.toolbar-divider {
display: none;
}
.composer-grid {
grid-template-columns: minmax(0, 1fr);
}
.composer-grid > * {
min-width: 0;
}
}
@@ -0,0 +1,187 @@
<section class="tasks-page">
<header class="hero">
<div class="hero-copy">
<div class="hero-badges">
<p class="eyebrow">Signal Store Feature</p>
<span class="hero-chip">Devtools Connected</span>
</div>
<h1>Delivery Board</h1>
<p class="lead">
A realistic feature slice with entity state, asynchronous updates,
persistence, and a clean operator flow for scalable Angular
teams.
</p>
</div>
<dl class="summary-grid">
<div>
<dt>Total</dt>
<dd>{{ store.totalCount() }}</dd>
</div>
<div>
<dt>Active</dt>
<dd>{{ store.activeCount() }}</dd>
</div>
<div>
<dt>Completed</dt>
<dd>{{ store.completedCount() }}</dd>
</div>
<div>
<dt>Synced</dt>
<dd>{{ store.lastSyncedLabel() }}</dd>
</div>
</dl>
</header>
<section class="workspace">
<form
class="composer"
(submit)="createTask(); $event.preventDefault()">
<div class="section-heading">
<div>
<p class="section-kicker">Compose</p>
<h2>Capture the next delivery item</h2>
</div>
<p class="section-note">
Fast input for planning work without leaving the board.
</p>
</div>
<div class="composer-grid">
<label>
<span>Task title</span>
<input
type="text"
placeholder="Add a task with product value"
[value]="draftTitle()"
(input)="updateDraftTitle($event)" />
</label>
<label>
<span>Priority</span>
<select
[value]="store.draftPriority()"
(change)="updateDraftPriority($event)">
@for (priority of priorities; track priority) {
<option [value]="priority">{{ priority }}</option>
}
</select>
</label>
<button
type="submit"
class="primary-action"
[disabled]="!canCreateTask()">
Create task
</button>
</div>
</form>
<div class="toolbar">
<div class="section-heading">
<div>
<p class="section-kicker">Refine</p>
<h2>Focus the board</h2>
</div>
<p class="section-note">
Search, segment, refresh, or archive completed work.
</p>
</div>
<div class="toolbar-grid">
<label class="search">
<span>Search</span>
<input
type="search"
placeholder="Filter tasks"
[value]="store.searchTerm()"
(input)="updateSearchTerm($event)" />
</label>
<div
class="filters"
role="group"
aria-label="Task filters">
@for (filter of filters; track filter) {
<button
type="button"
[class.active]="store.filter() === filter"
(click)="store.setFilter(filter)">
{{ filter }}
</button>
}
</div>
<div
class="toolbar-divider"
aria-hidden="true"></div>
<div
class="toolbar-actions"
role="group"
aria-label="Task actions">
<button
type="button"
class="ghost-action"
(click)="store.loadTasks()">
Refresh
</button>
<button
type="button"
class="primary-action"
[disabled]="!store.hasCompletedTasks() || store.loading()"
(click)="store.archiveCompleted()">
Archive completed
</button>
</div>
</div>
</div>
@if (store.error(); as errorMessage) {
<p
class="feedback error"
role="alert">
{{ errorMessage }}
</p>
}
@if (store.loading()) {
<p class="feedback">Synchronizing tasks...</p>
}
<ul class="task-list">
@for (task of store.filteredTasks(); track task.id) {
<li
class="task-card"
[class.done]="task.completed">
<div class="task-main">
<div class="task-status">
<span
class="priority-mark"
[class.high]="task.priority === 'high'"
[class.medium]="task.priority === 'medium'"
[class.low]="task.priority === 'low'"></span>
<button
type="button"
class="toggle"
[attr.aria-pressed]="task.completed"
(click)="store.toggleTask(task.id)">
{{ task.completed ? 'Completed' : 'Open' }}
</button>
</div>
<div class="task-copy">
<h2>{{ task.title }}</h2>
<div class="task-meta">
<span class="meta-pill">{{ task.priority }}</span>
<span>Updated {{ task.updatedAt }}</span>
</div>
</div>
</div>
</li>
} @empty {
<li class="empty-state">{{ store.emptyStateMessage() }}</li>
}
</ul>
</section>
</section>
@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { TasksStore } from '../data-access/tasks.store';
import { TaskFilter, TaskPriority } from '../data-access/task.model';
@Component({
selector: 'app-tasks-page',
templateUrl: './tasks-page.component.html',
styleUrl: './tasks-page.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TasksPageComponent {
readonly store = inject(TasksStore);
readonly draftTitle = signal('');
readonly canCreateTask = computed(() => this.draftTitle().trim().length > 0 && !this.store.loading());
readonly filters: TaskFilter[] = ['all', 'active', 'completed'];
readonly priorities: TaskPriority[] = ['low', 'medium', 'high'];
createTask(): void {
const title = this.draftTitle().trim();
if (!title) {
return;
}
this.store.createTask({
title,
priority: this.store.draftPriority(),
});
this.draftTitle.set('');
}
updateDraftTitle(event: Event): void {
const element = event.target as HTMLInputElement;
this.draftTitle.set(element.value);
}
updateSearchTerm(event: Event): void {
const element = event.target as HTMLInputElement;
this.store.setSearchTerm(element.value);
}
updateDraftPriority(event: Event): void {
const element = event.target as HTMLSelectElement;
this.store.setDraftPriority(element.value as TaskPriority);
}
}
+13 -8
View File
@@ -1,13 +1,18 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<title>NgrxPlayground</title> <title>NgrxPlayground</title>
<base href="/"> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta
<link rel="icon" type="image/x-icon" href="favicon.ico"> name="viewport"
</head> content="width=device-width, initial-scale=1" />
<body> <link
rel="icon"
type="image/x-icon"
href="favicon.ico" />
</head>
<body>
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>
+1 -2
View File
@@ -2,5 +2,4 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { App } from './app/app'; import { App } from './app/app';
bootstrapApplication(App, appConfig) bootstrapApplication(App, appConfig).catch((err) => console.error(err));
.catch((err) => console.error(err));
+2 -6
View File
@@ -6,10 +6,6 @@
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"types": [] "types": []
}, },
"include": [ "include": ["src/**/*.ts"],
"src/**/*.ts" "exclude": ["src/**/*.spec.ts"]
],
"exclude": [
"src/**/*.spec.ts"
]
} }
+2 -7
View File
@@ -4,12 +4,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": [ "types": ["vitest/globals"]
"vitest/globals"
]
}, },
"include": [ "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
} }