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

This commit is contained in:
Dennis Hundertmark
2026-03-08 09:50:17 +01:00
parent 2184971175
commit 9d13cc652a
47 changed files with 15272 additions and 14144 deletions
@@ -30,7 +30,7 @@ Use `createFeatureSelector` and `createSelector` for memoized state selection. S
**Example:** **Example:**
```typescript ```typescript
const selectCounterState = createFeatureSelector<CounterState>("counter"); const selectCounterState = createFeatureSelector<CounterState>('counter');
export const selectCount = createSelector(selectCounterState, (s) => s.count); export const selectCount = createSelector(selectCounterState, (s) => s.count);
``` ```
@@ -1,21 +1,21 @@
--- ---
name: angular-best-practices-ngrx name: angular-best-practices-ngrx
description: >- description: >-
NgRx state management best practices for Angular. Covers pure reducers, NgRx state management best practices for Angular. Covers pure reducers,
action groups, entity adapter, selectors, and signal-based selection. action groups, entity adapter, selectors, and signal-based selection.
Activates when working with @ngrx/store, @ngrx/effects, and @ngrx/entity. Activates when working with @ngrx/store, @ngrx/effects, and @ngrx/entity.
Do not use for Akita, NGXS, or standalone signal-based state. Do not use for Akita, NGXS, or standalone signal-based state.
Install alongside angular-best-practices for full coverage. Install alongside angular-best-practices for full coverage.
license: MIT license: MIT
metadata: metadata:
author: alfredoperez author: alfredoperez
version: "1.2.0" version: '1.2.0'
tags: [angular, ngrx, state-management, redux] tags: [angular, ngrx, state-management, redux]
globs: globs:
- "**/*.ts" - '**/*.ts'
- "**/*.reducer.ts" - '**/*.reducer.ts'
- "**/*.effects.ts" - '**/*.effects.ts'
- "**/*.selectors.ts" - '**/*.selectors.ts'
--- ---
# Angular NgRx Best Practices # Angular NgRx Best Practices
+128 -128
View File
@@ -10,58 +10,54 @@ Create standalone components for Angular v20+. Components are standalone by defa
## Component Structure ## Component Structure
```typescript ```typescript
import { import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
Component,
ChangeDetectionStrategy,
input,
output,
computed,
} from "@angular/core";
@Component({ @Component({
selector: "app-user-card", selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { host: {
class: "user-card", 'class': 'user-card',
"[class.active]": "isActive()", '[class.active]': 'isActive()',
"(click)": "handleClick()", '(click)': 'handleClick()',
}, },
template: ` template: `
<img [src]="avatarUrl()" [alt]="name() + ' avatar'" /> <img
<h2>{{ name() }}</h2> [src]="avatarUrl()"
@if (showEmail()) { [alt]="name() + ' avatar'" />
<p>{{ email() }}</p> <h2>{{ name() }}</h2>
} @if (showEmail()) {
`, <p>{{ email() }}</p>
styles: ` }
:host { `,
display: block; styles: `
} :host {
:host.active { display: block;
border: 2px solid blue; }
} :host.active {
`, border: 2px solid blue;
}
`,
}) })
export class UserCard { export class UserCard {
// Required input // Required input
name = input.required<string>(); name = input.required<string>();
// Optional input with default // Optional input with default
email = input<string>(""); email = input<string>('');
showEmail = input(false); showEmail = input(false);
// Input with transform // Input with transform
isActive = input(false, { transform: booleanAttribute }); isActive = input(false, { transform: booleanAttribute });
// Computed from inputs // Computed from inputs
avatarUrl = computed(() => `https://api.example.com/avatar/${this.name()}`); avatarUrl = computed(() => `https://api.example.com/avatar/${this.name()}`);
// Output // Output
selected = output<string>(); selected = output<string>();
handleClick() { handleClick() {
this.selected.emit(this.name()); this.selected.emit(this.name());
} }
} }
``` ```
@@ -78,7 +74,7 @@ count = input(0);
label = input<string>(); label = input<string>();
// With alias for template binding // With alias for template binding
size = input("medium", { alias: "buttonSize" }); size = input('medium', { alias: 'buttonSize' });
// With transform function // With transform function
disabled = input(false, { transform: booleanAttribute }); disabled = input(false, { transform: booleanAttribute });
@@ -88,14 +84,14 @@ value = input(0, { transform: numberAttribute });
## Signal Outputs ## Signal Outputs
```typescript ```typescript
import { output, outputFromObservable } from "@angular/core"; import { output, outputFromObservable } from '@angular/core';
// Basic output // Basic output
clicked = output<void>(); clicked = output<void>();
selected = output<Item>(); selected = output<Item>();
// With alias // With alias
valueChange = output<number>({ alias: "change" }); valueChange = output<number>({ alias: 'change' });
// From Observable (for RxJS interop) // From Observable (for RxJS interop)
scroll$ = new Subject<number>(); scroll$ = new Subject<number>();
@@ -112,41 +108,41 @@ Use the `host` object in `@Component`—do NOT use `@HostBinding` or `@HostListe
```typescript ```typescript
@Component({ @Component({
selector: "app-button", selector: 'app-button',
host: { host: {
// Static attributes // Static attributes
role: "button", 'role': 'button',
// Dynamic class bindings // Dynamic class bindings
"[class.primary]": 'variant() === "primary"', '[class.primary]': 'variant() === "primary"',
"[class.disabled]": "disabled()", '[class.disabled]': 'disabled()',
// Dynamic style bindings // Dynamic style bindings
"[style.--btn-color]": "color()", '[style.--btn-color]': 'color()',
// Attribute bindings // Attribute bindings
"[attr.aria-disabled]": "disabled()", '[attr.aria-disabled]': 'disabled()',
"[attr.tabindex]": "disabled() ? -1 : 0", '[attr.tabindex]': 'disabled() ? -1 : 0',
// Event listeners // Event listeners
"(click)": "onClick($event)", '(click)': 'onClick($event)',
"(keydown.enter)": "onClick($event)", '(keydown.enter)': 'onClick($event)',
"(keydown.space)": "onClick($event)", '(keydown.space)': 'onClick($event)',
}, },
template: `<ng-content />`, template: `<ng-content />`,
}) })
export class Button { export class Button {
variant = input<"primary" | "secondary">("primary"); variant = input<'primary' | 'secondary'>('primary');
disabled = input(false, { transform: booleanAttribute }); disabled = input(false, { transform: booleanAttribute });
color = input("#007bff"); color = input('#007bff');
clicked = output<void>(); clicked = output<void>();
onClick(event: Event) { onClick(event: Event) {
if (!this.disabled()) { if (!this.disabled()) {
this.clicked.emit(); this.clicked.emit();
}
} }
}
} }
``` ```
@@ -154,18 +150,18 @@ export class Button {
```typescript ```typescript
@Component({ @Component({
selector: "app-card", selector: 'app-card',
template: ` template: `
<header> <header>
<ng-content select="[card-header]" /> <ng-content select="[card-header]" />
</header> </header>
<main> <main>
<ng-content /> <ng-content />
</main> </main>
<footer> <footer>
<ng-content select="[card-footer]" /> <ng-content select="[card-footer]" />
</footer> </footer>
`, `,
}) })
export class Card {} export class Card {}
@@ -180,26 +176,26 @@ export class Card {}
## Lifecycle Hooks ## Lifecycle Hooks
```typescript ```typescript
import { OnDestroy, OnInit, afterNextRender, afterRender } from "@angular/core"; import { OnDestroy, OnInit, afterNextRender, afterRender } from '@angular/core';
export class My implements OnInit, OnDestroy { export class My implements OnInit, OnDestroy {
constructor() { constructor() {
// For DOM manipulation after render (SSR-safe) // For DOM manipulation after render (SSR-safe)
afterNextRender(() => { afterNextRender(() => {
// Runs once after first render // Runs once after first render
}); });
afterRender(() => { afterRender(() => {
// Runs after every render // Runs after every render
}); });
} }
ngOnInit() { ngOnInit() {
/* Component initialized */ /* Component initialized */
} }
ngOnDestroy() { ngOnDestroy() {
/* Cleanup */ /* Cleanup */
} }
} }
``` ```
@@ -215,28 +211,26 @@ Components MUST:
```typescript ```typescript
@Component({ @Component({
selector: "app-toggle", selector: 'app-toggle',
host: { host: {
role: "switch", 'role': 'switch',
"[attr.aria-checked]": "checked()", '[attr.aria-checked]': 'checked()',
"[attr.aria-label]": "label()", '[attr.aria-label]': 'label()',
tabindex: "0", 'tabindex': '0',
"(click)": "toggle()", '(click)': 'toggle()',
"(keydown.enter)": "toggle()", '(keydown.enter)': 'toggle()',
"(keydown.space)": "toggle(); $event.preventDefault()", '(keydown.space)': 'toggle(); $event.preventDefault()',
}, },
template: `<span class="toggle-track" template: `<span class="toggle-track"><span class="toggle-thumb"></span></span>`,
><span class="toggle-thumb"></span
></span>`,
}) })
export class Toggle { export class Toggle {
label = input.required<string>(); label = input.required<string>();
checked = input(false, { transform: booleanAttribute }); checked = input(false, { transform: booleanAttribute });
checkedChange = output<boolean>(); checkedChange = output<boolean>();
toggle() { toggle() {
this.checkedChange.emit(!this.checked()); this.checkedChange.emit(!this.checked());
} }
} }
``` ```
@@ -262,8 +256,7 @@ Use native control flow—do NOT use `*ngIf`, `*ngFor`, `*ngSwitch`.
} }
<!-- Switch --> <!-- Switch -->
@switch (status()) { @case ('pending') { <span>Pending</span> } @case ('active') @switch (status()) { @case ('pending') { <span>Pending</span> } @case ('active') { <span>Active</span> } @default { <span>Unknown</span> } }
{ <span>Active</span> } @default { <span>Unknown</span> } }
``` ```
## Class and Style Bindings ## Class and Style Bindings
@@ -285,17 +278,24 @@ Do NOT use `ngClass` or `ngStyle`. Use direct bindings:
Use `NgOptimizedImage` for static images: Use `NgOptimizedImage` for static images:
```typescript ```typescript
import { NgOptimizedImage } from "@angular/common"; import { NgOptimizedImage } from '@angular/common';
@Component({ @Component({
imports: [NgOptimizedImage], imports: [NgOptimizedImage],
template: ` template: `
<img ngSrc="/assets/hero.jpg" width="800" height="600" priority /> <img
<img [ngSrc]="imageUrl()" width="200" height="200" /> ngSrc="/assets/hero.jpg"
`, width="800"
height="600"
priority />
<img
width="200"
height="200"
[ngSrc]="imageUrl()" />
`,
}) })
export class Hero { export class Hero {
imageUrl = input.required<string>(); imageUrl = input.required<string>();
} }
``` ```
@@ -14,28 +14,32 @@
For two-way binding with `[(value)]` syntax: For two-way binding with `[(value)]` syntax:
```typescript ```typescript
import { Component, model } from "@angular/core"; import { Component, model } from '@angular/core';
@Component({ @Component({
selector: "app-slider", selector: 'app-slider',
host: { host: {
"(input)": "onInput($event)", '(input)': 'onInput($event)',
}, },
template: ` template: `
<input type="range" [value]="value()" [min]="min()" [max]="max()" /> <input
<span>{{ value() }}</span> type="range"
`, [value]="value()"
[min]="min()"
[max]="max()" />
<span>{{ value() }}</span>
`,
}) })
export class Slider { export class Slider {
// Model creates both input and output // Model creates both input and output
value = model(0); value = model(0);
min = input(0); min = input(0);
max = input(100); max = input(100);
onInput(event: Event) { onInput(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
this.value.set(Number(target.value)); this.value.set(Number(target.value));
} }
} }
// Usage: <app-slider [(value)]="sliderValue" /> // Usage: <app-slider [(value)]="sliderValue" />
@@ -52,29 +56,31 @@ value = model.required<number>();
Query elements and components in the template: Query elements and components in the template:
```typescript ```typescript
import { Component, viewChild, viewChildren, ElementRef } from "@angular/core"; import { Component, viewChild, viewChildren, ElementRef } from '@angular/core';
@Component({ @Component({
selector: "app-gallery", selector: 'app-gallery',
template: ` template: `
<div #container class="gallery"> <div
@for (image of images(); track image.id) { #container
<app-image-card [image]="image" /> class="gallery">
} @for (image of images(); track image.id) {
</div> <app-image-card [image]="image" />
`, }
</div>
`,
}) })
export class Gallery { export class Gallery {
images = input.required<Image[]>(); images = input.required<Image[]>();
// Query single element // Query single element
container = viewChild.required<ElementRef<HTMLDivElement>>("container"); container = viewChild.required<ElementRef<HTMLDivElement>>('container');
// Query single component (optional) // Query single component (optional)
firstCard = viewChild(ImageCard); firstCard = viewChild(ImageCard);
// Query all matching components // Query all matching components
allCards = viewChildren(ImageCard); allCards = viewChildren(ImageCard);
} }
``` ```
@@ -83,64 +89,60 @@ export class Gallery {
Query projected content: Query projected content:
```typescript ```typescript
import { import { Component, contentChild, contentChildren, effect, signal } from '@angular/core';
Component,
contentChild,
contentChildren,
effect,
signal,
} from "@angular/core";
@Component({ @Component({
selector: "app-tabs", selector: 'app-tabs',
template: ` template: `
<div class="tab-headers"> <div class="tab-headers">
@for (tab of tabs(); track tab.label()) { @for (tab of tabs(); track tab.label()) {
<button [class.active]="tab === activeTab()" (click)="selectTab(tab)"> <button
{{ tab.label() }} [class.active]="tab === activeTab()"
</button> (click)="selectTab(tab)">
} {{ tab.label() }}
</div> </button>
<div class="tab-content"> }
<ng-content /> </div>
</div> <div class="tab-content">
`, <ng-content />
</div>
`,
}) })
export class Tabs { export class Tabs {
// Query all projected Tab children // Query all projected Tab children
tabs = contentChildren(Tab); tabs = contentChildren(Tab);
// Query single projected element // Query single projected element
header = contentChild("tabHeader"); header = contentChild('tabHeader');
activeTab = signal<Tab | undefined>(undefined); activeTab = signal<Tab | undefined>(undefined);
constructor() { constructor() {
// Set first tab as active when tabs are available // Set first tab as active when tabs are available
effect(() => { effect(() => {
const firstTab = this.tabs()[0]; const firstTab = this.tabs()[0];
if (firstTab && !this.activeTab()) { if (firstTab && !this.activeTab()) {
this.activeTab.set(firstTab); this.activeTab.set(firstTab);
} }
}); });
} }
selectTab(tab: Tab) { selectTab(tab: Tab) {
this.activeTab.set(tab); this.activeTab.set(tab);
} }
} }
@Component({ @Component({
selector: "app-tab", selector: 'app-tab',
template: `<ng-content />`, template: `<ng-content />`,
host: { host: {
"[class.active]": "isActive()", '[class.active]': 'isActive()',
"[style.display]": 'isActive() ? "block" : "none"', '[style.display]': 'isActive() ? "block" : "none"',
}, },
}) })
export class Tab { export class Tab {
label = input.required<string>(); label = input.required<string>();
isActive = input(false); isActive = input(false);
} }
``` ```
@@ -149,27 +151,27 @@ export class Tab {
Use `inject()` function instead of constructor injection: Use `inject()` function instead of constructor injection:
```typescript ```typescript
import { Component, inject } from "@angular/core"; import { Component, inject } from '@angular/core';
import { Router } from "@angular/router"; import { Router } from '@angular/router';
@Component({ @Component({
selector: "app-dashboard", selector: 'app-dashboard',
template: `...`, template: `...`,
}) })
export class Dashboard { export class Dashboard {
private router = inject(Router); private router = inject(Router);
private userService = inject(User); private userService = inject(User);
private config = inject(APP_CONFIG); private config = inject(APP_CONFIG);
// Optional injection // Optional injection
private analytics = inject(Analytics, { optional: true }); private analytics = inject(Analytics, { optional: true });
// Self-only injection // Self-only injection
private localService = inject(Local, { self: true }); private localService = inject(Local, { self: true });
navigateToProfile() { navigateToProfile() {
this.router.navigate(["/profile"]); this.router.navigate(['/profile']);
} }
} }
``` ```
@@ -180,18 +182,20 @@ export class Dashboard {
```typescript ```typescript
// Parent // Parent
@Component({ @Component({
template: `<app-child [data]="parentData()" [config]="config" />`, template: `<app-child
[data]="parentData()"
[config]="config" />`,
}) })
export class Parent { export class Parent {
parentData = signal({ name: "Test" }); parentData = signal({ name: 'Test' });
config = { theme: "dark" }; config = { theme: 'dark' };
} }
// Child // Child
@Component({ selector: "app-child" }) @Component({ selector: 'app-child' })
export class Child { export class Child {
data = input.required<Data>(); data = input.required<Data>();
config = input<Config>(); config = input<Config>();
} }
``` ```
@@ -200,25 +204,25 @@ export class Child {
```typescript ```typescript
// Child // Child
@Component({ @Component({
selector: "app-child", selector: 'app-child',
template: `<button (click)="save()">Save</button>`, template: `<button (click)="save()">Save</button>`,
}) })
export class Child { export class Child {
saved = output<Data>(); saved = output<Data>();
save() { save() {
this.saved.emit({ id: 1, name: "Item" }); this.saved.emit({ id: 1, name: 'Item' });
} }
} }
// Parent // Parent
@Component({ @Component({
template: `<app-child (saved)="onSaved($event)" />`, template: `<app-child (saved)="onSaved($event)" />`,
}) })
export class Parent { export class Parent {
onSaved(data: Data) { onSaved(data: Data) {
console.log("Saved:", data); console.log('Saved:', data);
} }
} }
``` ```
@@ -226,39 +230,37 @@ export class Parent {
```typescript ```typescript
// Shared state service // Shared state service
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class Cart { export class Cart {
private items = signal<CartItem[]>([]); private items = signal<CartItem[]>([]);
readonly items$ = this.items.asReadonly(); readonly items$ = this.items.asReadonly();
readonly total = computed(() => readonly total = computed(() => this.items().reduce((sum, item) => sum + item.price, 0));
this.items().reduce((sum, item) => sum + item.price, 0),
);
addItem(item: CartItem) { addItem(item: CartItem) {
this.items.update((items) => [...items, item]); this.items.update((items) => [...items, item]);
} }
removeItem(id: string) { removeItem(id: string) {
this.items.update((items) => items.filter((i) => i.id !== id)); this.items.update((items) => items.filter((i) => i.id !== id));
} }
} }
// Component A // Component A
@Component({ template: `<button (click)="add()">Add</button>` }) @Component({ template: `<button (click)="add()">Add</button>` })
export class Product { export class Product {
private cart = inject(Cart); private cart = inject(Cart);
product = input.required<Product>(); product = input.required<Product>();
add() { add() {
this.cart.addItem({ ...this.product(), quantity: 1 }); this.cart.addItem({ ...this.product(), quantity: 1 });
} }
} }
// Component B // Component B
@Component({ template: `<span>Total: {{ cart.total() }}</span>` }) @Component({ template: `<span>Total: {{ cart.total() }}</span>` })
export class CartSummary { export class CartSummary {
cart = inject(Cart); cart = inject(Cart);
} }
``` ```
@@ -268,20 +270,20 @@ Using `@defer` for lazy loading:
```typescript ```typescript
@Component({ @Component({
template: ` template: `
@defer (on viewport) { @defer (on viewport) {
<app-heavy-chart [data]="chartData()" /> <app-heavy-chart [data]="chartData()" />
} @placeholder { } @placeholder {
<div class="chart-placeholder">Loading chart...</div> <div class="chart-placeholder">Loading chart...</div>
} @loading (minimum 500ms) { } @loading (minimum 500ms) {
<app-spinner /> <app-spinner />
} @error { } @error {
<p>Failed to load chart</p> <p>Failed to load chart</p>
} }
`, `,
}) })
export class Dashboard { export class Dashboard {
chartData = input.required<ChartData>(); chartData = input.required<ChartData>();
} }
``` ```
@@ -297,16 +299,16 @@ Defer triggers:
```typescript ```typescript
@Component({ @Component({
template: ` template: `
@defer (on interaction; prefetch on idle) { @defer (on interaction; prefetch on idle) {
<app-comments [postId]="postId()" /> <app-comments [postId]="postId()" />
} @placeholder { } @placeholder {
<button>Load Comments</button> <button>Load Comments</button>
} }
`, `,
}) })
export class Post { export class Post {
postId = input.required<string>(); postId = input.required<string>();
} }
``` ```
@@ -314,19 +316,19 @@ export class Post {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appHighlight]", selector: '[appHighlight]',
host: { host: {
"[style.backgroundColor]": "color()", '[style.backgroundColor]': 'color()',
}, },
}) })
export class Highlight { export class Highlight {
color = input("yellow", { alias: "appHighlight" }); color = input('yellow', { alias: 'appHighlight' });
} }
// Usage on component // Usage on component
@Component({ @Component({
imports: [Highlight], imports: [Highlight],
template: `<app-card appHighlight="lightblue" />`, template: `<app-card appHighlight="lightblue" />`,
}) })
export class Page {} export class Page {}
``` ```
@@ -335,24 +337,24 @@ export class Page {}
```typescript ```typescript
@Component({ @Component({
selector: "app-error-boundary", selector: 'app-error-boundary',
template: ` template: `
@if (hasError()) { @if (hasError()) {
<div class="error"> <div class="error">
<h3>Something went wrong</h3> <h3>Something went wrong</h3>
<button (click)="retry()">Retry</button> <button (click)="retry()">Retry</button>
</div> </div>
} @else { } @else {
<ng-content /> <ng-content />
} }
`, `,
}) })
export class ErrorBoundary { export class ErrorBoundary {
hasError = signal(false); hasError = signal(false);
private errorHandler = inject(ErrorHandler); private errorHandler = inject(ErrorHandler);
retry() { retry() {
this.hasError.set(false); this.hasError.set(false);
} }
} }
``` ```
+104 -116
View File
@@ -14,43 +14,43 @@ Configure and use dependency injection in Angular v20+ with `inject()` and provi
Prefer `inject()` over constructor injection: Prefer `inject()` over constructor injection:
```typescript ```typescript
import { Component, inject } from "@angular/core"; import { Component, inject } from '@angular/core';
import { HttpClient } from "@angular/common/http"; import { HttpClient } from '@angular/common/http';
import { User } from "./user.service"; import { User } from './user.service';
@Component({ @Component({
selector: "app-user-list", selector: 'app-user-list',
template: `...`, template: `...`,
}) })
export class UserList { export class UserList {
// Inject dependencies // Inject dependencies
private http = inject(HttpClient); private http = inject(HttpClient);
private userService = inject(User); private userService = inject(User);
// Can use immediately // Can use immediately
users = this.userService.getUsers(); users = this.userService.getUsers();
} }
``` ```
### Injectable Services ### Injectable Services
```typescript ```typescript
import { Injectable, inject, signal } from "@angular/core"; import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from "@angular/common/http"; import { HttpClient } from '@angular/common/http';
@Injectable({ @Injectable({
providedIn: "root", // Singleton at root level providedIn: 'root', // Singleton at root level
}) })
export class User { export class User {
private http = inject(HttpClient); private http = inject(HttpClient);
private users = signal<User[]>([]); private users = signal<User[]>([]);
readonly users$ = this.users.asReadonly(); readonly users$ = this.users.asReadonly();
async loadUsers() { async loadUsers() {
const users = await firstValueFrom(this.http.get<User[]>("/api/users")); const users = await firstValueFrom(this.http.get<User[]>('/api/users'));
this.users.set(users); this.users.set(users);
} }
} }
``` ```
@@ -61,13 +61,13 @@ export class User {
```typescript ```typescript
// Recommended: providedIn // Recommended: providedIn
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class Auth {} export class Auth {}
// Alternative: in app.config.ts // Alternative: in app.config.ts
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [Auth], providers: [Auth],
}; };
``` ```
@@ -75,12 +75,12 @@ export const appConfig: ApplicationConfig = {
```typescript ```typescript
@Component({ @Component({
selector: "app-editor", selector: 'app-editor',
providers: [EditorState], // New instance for each component providers: [EditorState], // New instance for each component
template: `...`, template: `...`,
}) })
export class Editor { export class Editor {
private editorState = inject(EditorState); private editorState = inject(EditorState);
} }
``` ```
@@ -88,14 +88,14 @@ export class Editor {
```typescript ```typescript
export const routes: Routes = [ export const routes: Routes = [
{ {
path: "admin", path: 'admin',
providers: [Admin], // Shared within this route tree providers: [Admin], // Shared within this route tree
children: [ children: [
{ path: "", component: AdminDashboard }, { path: '', component: AdminDashboard },
{ path: "users", component: AdminUsers }, { path: 'users', component: AdminUsers },
], ],
}, },
]; ];
``` ```
@@ -104,31 +104,31 @@ export const routes: Routes = [
### Creating Tokens ### Creating Tokens
```typescript ```typescript
import { InjectionToken } from "@angular/core"; import { InjectionToken } from '@angular/core';
// Simple value token // Simple value token
export const API_URL = new InjectionToken<string>("API_URL"); export const API_URL = new InjectionToken<string>('API_URL');
// Object token // Object token
export interface AppConfig { export interface AppConfig {
apiUrl: string; apiUrl: string;
features: { features: {
darkMode: boolean; darkMode: boolean;
analytics: boolean; analytics: boolean;
}; };
} }
export const APP_CONFIG = new InjectionToken<AppConfig>("APP_CONFIG"); export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
// Token with factory (self-providing) // Token with factory (self-providing)
export const WINDOW = new InjectionToken<Window>("Window", { export const WINDOW = new InjectionToken<Window>('Window', {
providedIn: "root", providedIn: 'root',
factory: () => window, factory: () => window,
}); });
export const LOCAL_STORAGE = new InjectionToken<Storage>("LocalStorage", { export const LOCAL_STORAGE = new InjectionToken<Storage>('LocalStorage', {
providedIn: "root", providedIn: 'root',
factory: () => localStorage, factory: () => localStorage,
}); });
``` ```
@@ -137,31 +137,31 @@ export const LOCAL_STORAGE = new InjectionToken<Storage>("LocalStorage", {
```typescript ```typescript
// app.config.ts // app.config.ts
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
{ provide: API_URL, useValue: "https://api.example.com" }, { provide: API_URL, useValue: 'https://api.example.com' },
{ {
provide: APP_CONFIG, provide: APP_CONFIG,
useValue: { useValue: {
apiUrl: "https://api.example.com", apiUrl: 'https://api.example.com',
features: { darkMode: true, analytics: true }, features: { darkMode: true, analytics: true },
}, },
}, },
], ],
}; };
``` ```
### Injecting Tokens ### Injecting Tokens
```typescript ```typescript
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class Api { export class Api {
private apiUrl = inject(API_URL); private apiUrl = inject(API_URL);
private config = inject(APP_CONFIG); private config = inject(APP_CONFIG);
private window = inject(WINDOW); private window = inject(WINDOW);
getBaseUrl(): string { getBaseUrl(): string {
return this.apiUrl; return this.apiUrl;
} }
} }
``` ```
@@ -268,23 +268,23 @@ Collect multiple values for same token:
```typescript ```typescript
// Token for multiple validators // Token for multiple validators
export const VALIDATORS = new InjectionToken<Validator[]>("Validators"); export const VALIDATORS = new InjectionToken<Validator[]>('Validators');
// Provide multiple values // Provide multiple values
providers: [ providers: [
{ provide: VALIDATORS, useClass: RequiredValidator, multi: true }, { provide: VALIDATORS, useClass: RequiredValidator, multi: true },
{ provide: VALIDATORS, useClass: EmailValidator, multi: true }, { provide: VALIDATORS, useClass: EmailValidator, multi: true },
{ provide: VALIDATORS, useClass: MinLengthValidator, multi: true }, { provide: VALIDATORS, useClass: MinLengthValidator, multi: true },
]; ];
// Inject as array // Inject as array
@Injectable() @Injectable()
export class Validation { export class Validation {
private validators = inject(VALIDATORS); // Validator[] private validators = inject(VALIDATORS); // Validator[]
validate(value: string): ValidationError[] { validate(value: string): ValidationError[] {
return this.validators.map((v) => v.validate(value)).filter(Boolean); return this.validators.map((v) => v.validate(value)).filter(Boolean);
} }
} }
``` ```
@@ -293,11 +293,7 @@ export class Validation {
```typescript ```typescript
// Interceptors use multi providers internally // Interceptors use multi providers internally
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [provideHttpClient(withInterceptors([authInterceptor, loggingInterceptor, errorInterceptor]))],
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor, errorInterceptor]),
),
],
}; };
``` ```
@@ -306,16 +302,16 @@ export const appConfig: ApplicationConfig = {
Run async code before app starts using `provideAppInitializer`: Run async code before app starts using `provideAppInitializer`:
```typescript ```typescript
import { provideAppInitializer, inject } from "@angular/core"; import { provideAppInitializer, inject } from '@angular/core';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
Config, Config,
provideAppInitializer(() => { provideAppInitializer(() => {
const configService = inject(Config); const configService = inject(Config);
return configService.loadConfig(); return configService.loadConfig();
}), }),
], ],
}; };
``` ```
@@ -323,14 +319,14 @@ export const appConfig: ApplicationConfig = {
```typescript ```typescript
providers: [ providers: [
provideAppInitializer(() => { provideAppInitializer(() => {
const config = inject(Config); const config = inject(Config);
return config.load(); return config.load();
}), }),
provideAppInitializer(() => { provideAppInitializer(() => {
const auth = inject(Auth); const auth = inject(Auth);
return auth.checkSession(); return auth.checkSession();
}), }),
]; ];
``` ```
@@ -339,19 +335,15 @@ providers: [
Create injectors programmatically: Create injectors programmatically:
```typescript ```typescript
import { import { createEnvironmentInjector, EnvironmentInjector, inject } from '@angular/core';
createEnvironmentInjector,
EnvironmentInjector,
inject,
} from "@angular/core";
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class Plugin { export class Plugin {
private parentInjector = inject(EnvironmentInjector); private parentInjector = inject(EnvironmentInjector);
loadPlugin(providers: Provider[]): EnvironmentInjector { loadPlugin(providers: Provider[]): EnvironmentInjector {
return createEnvironmentInjector(providers, this.parentInjector); return createEnvironmentInjector(providers, this.parentInjector);
} }
} }
``` ```
@@ -360,25 +352,21 @@ export class Plugin {
Run code with injection context: Run code with injection context:
```typescript ```typescript
import { import { runInInjectionContext, EnvironmentInjector, inject } from '@angular/core';
runInInjectionContext,
EnvironmentInjector,
inject,
} from "@angular/core";
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class Utility { export class Utility {
private injector = inject(EnvironmentInjector); private injector = inject(EnvironmentInjector);
executeWithDI<T>(fn: () => T): T { executeWithDI<T>(fn: () => T): T {
return runInInjectionContext(this.injector, fn); return runInInjectionContext(this.injector, fn);
} }
} }
// Usage // Usage
utilityService.executeWithDI(() => { utilityService.executeWithDI(() => {
const http = inject(HttpClient); const http = inject(HttpClient);
// Use http... // Use http...
}); });
``` ```
@@ -16,31 +16,31 @@
Combine multiple services into a single API: Combine multiple services into a single API:
```typescript ```typescript
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class ShopFacade { export class ShopFacade {
private productService = inject(Product); private productService = inject(Product);
private cartService = inject(Cart); private cartService = inject(Cart);
private orderService = inject(Order); private orderService = inject(Order);
// Expose combined state // Expose combined state
readonly products = this.productService.products; readonly products = this.productService.products;
readonly cart = this.cartService.items; readonly cart = this.cartService.items;
readonly cartTotal = this.cartService.total; readonly cartTotal = this.cartService.total;
// Unified actions // Unified actions
addToCart(productId: string, quantity: number) { addToCart(productId: string, quantity: number) {
const product = this.productService.getById(productId); const product = this.productService.getById(productId);
if (product) { if (product) {
this.cartService.add(product, quantity); this.cartService.add(product, quantity);
}
} }
}
async checkout() { async checkout() {
const items = this.cartService.items(); const items = this.cartService.items();
const order = await this.orderService.create(items); const order = await this.orderService.create(items);
this.cartService.clear(); this.cartService.clear();
return order; return order;
} }
} }
``` ```
@@ -48,41 +48,41 @@ export class ShopFacade {
```typescript ```typescript
interface UserState { interface UserState {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
} }
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class UserState { export class UserState {
private state = signal<UserState>({ private state = signal<UserState>({
user: null, user: null,
loading: false, loading: false,
error: null, error: null,
}); });
// Selectors // Selectors
readonly user = computed(() => this.state().user); readonly user = computed(() => this.state().user);
readonly loading = computed(() => this.state().loading); readonly loading = computed(() => this.state().loading);
readonly error = computed(() => this.state().error); readonly error = computed(() => this.state().error);
readonly isAuthenticated = computed(() => this.state().user !== null); readonly isAuthenticated = computed(() => this.state().user !== null);
// Actions // Actions
setUser(user: User) { setUser(user: User) {
this.state.update((s) => ({ ...s, user, loading: false, error: null })); this.state.update((s) => ({ ...s, user, loading: false, error: null }));
} }
setLoading() { setLoading() {
this.state.update((s) => ({ ...s, loading: true, error: null })); this.state.update((s) => ({ ...s, loading: true, error: null }));
} }
setError(error: string) { setError(error: string) {
this.state.update((s) => ({ ...s, loading: false, error })); this.state.update((s) => ({ ...s, loading: false, error }));
} }
clear() { clear() {
this.state.set({ user: null, loading: false, error: null }); this.state.set({ user: null, loading: false, error: null });
} }
} }
``` ```
@@ -208,36 +208,36 @@ export class User {
```typescript ```typescript
// Parent provides service // Parent provides service
@Component({ @Component({
selector: "app-form-container", selector: 'app-form-container',
providers: [FormState], providers: [FormState],
template: ` template: `
<app-form-header /> <app-form-header />
<app-form-body /> <app-form-body />
<app-form-footer /> <app-form-footer />
`, `,
}) })
export class FormContainer { export class FormContainer {
private formState = inject(FormState); private formState = inject(FormState);
} }
// Children share same instance // Children share same instance
@Component({ @Component({
selector: "app-form-body", selector: 'app-form-body',
template: `...`, template: `...`,
}) })
export class FormBody { export class FormBody {
// Gets same instance as parent // Gets same instance as parent
private formState = inject(FormState); private formState = inject(FormState);
} }
// Grandchildren also share // Grandchildren also share
@Component({ @Component({
selector: "app-form-field", selector: 'app-form-field',
template: `...`, template: `...`,
}) })
export class FormField { export class FormField {
// Gets same instance from ancestor // Gets same instance from ancestor
private formState = inject(FormState); private formState = inject(FormState);
} }
``` ```
@@ -245,20 +245,20 @@ export class FormField {
```typescript ```typescript
@Component({ @Component({
selector: "app-tabs", selector: 'app-tabs',
// providers: Available to component AND content children // providers: Available to component AND content children
providers: [TabsSvc], providers: [TabsSvc],
// viewProviders: Available to component AND view children only // viewProviders: Available to component AND view children only
// NOT available to content children (<ng-content>) // NOT available to content children (<ng-content>)
viewProviders: [InternalTabs], viewProviders: [InternalTabs],
template: ` template: `
<div class="tabs"> <div class="tabs">
<ng-content /> <ng-content />
<!-- Content children can't access viewProviders --> <!-- Content children can't access viewProviders -->
</div> </div>
`, `,
}) })
export class Tabs {} export class Tabs {}
``` ```
@@ -338,56 +338,56 @@ import { PLATFORM_ID, isPlatformBrowser } from '@angular/common';
### Mocking Services ### Mocking Services
```typescript ```typescript
describe("UserCmpt", () => { describe('UserCmpt', () => {
let userServiceSpy: jasmine.SpyObj<User>; let userServiceSpy: jasmine.SpyObj<User>;
beforeEach(async () => { beforeEach(async () => {
userServiceSpy = jasmine.createSpyObj("User", ["getUser", "updateUser"]); userServiceSpy = jasmine.createSpyObj('User', ['getUser', 'updateUser']);
userServiceSpy.getUser.and.returnValue(of({ id: "1", name: "Test" })); userServiceSpy.getUser.and.returnValue(of({ id: '1', name: 'Test' }));
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [UserCmpt], imports: [UserCmpt],
providers: [{ provide: User, useValue: userServiceSpy }], providers: [{ provide: User, useValue: userServiceSpy }],
}).compileComponents(); }).compileComponents();
}); });
it("should load user", () => { it('should load user', () => {
const fixture = TestBed.createComponent(UserCmpt); const fixture = TestBed.createComponent(UserCmpt);
fixture.detectChanges(); fixture.detectChanges();
expect(userServiceSpy.getUser).toHaveBeenCalled(); expect(userServiceSpy.getUser).toHaveBeenCalled();
}); });
}); });
``` ```
### Overriding Providers ### Overriding Providers
```typescript ```typescript
describe("with different config", () => { describe('with different config', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
}) })
.overrideProvider(APP_CONFIG, { .overrideProvider(APP_CONFIG, {
useValue: { apiUrl: "http://test-api.com" }, useValue: { apiUrl: 'http://test-api.com' },
}) })
.compileComponents(); .compileComponents();
}); });
}); });
``` ```
### Testing Injection Tokens ### Testing Injection Tokens
```typescript ```typescript
describe("API_URL token", () => { describe('API_URL token', () => {
it("should provide correct URL", () => { it('should provide correct URL', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [{ provide: API_URL, useValue: "https://api.test.com" }], providers: [{ provide: API_URL, useValue: 'https://api.test.com' }],
}); });
const apiUrl = TestBed.inject(API_URL); const apiUrl = TestBed.inject(API_URL);
expect(apiUrl).toBe("https://api.test.com"); expect(apiUrl).toBe('https://api.test.com');
}); });
}); });
``` ```
@@ -430,18 +430,18 @@ export class Data {
```typescript ```typescript
@Injectable() @Injectable()
export class WebSocket { export class WebSocket {
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
constructor() { constructor() {
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {
this.socket?.close(); this.socket?.close();
}); });
} }
connect(url: string) { connect(url: string) {
this.socket = new WebSocket(url); this.socket = new WebSocket(url);
} }
} }
``` ```
+227 -237
View File
@@ -12,22 +12,22 @@ Create custom directives for reusable DOM manipulation and behavior in Angular v
Modify the appearance or behavior of an element: Modify the appearance or behavior of an element:
```typescript ```typescript
import { Directive, input, effect, inject, ElementRef } from "@angular/core"; import { Directive, input, effect, inject, ElementRef } from '@angular/core';
@Directive({ @Directive({
selector: "[appHighlight]", selector: '[appHighlight]',
}) })
export class Highlight { export class Highlight {
private el = inject(ElementRef<HTMLElement>); private el = inject(ElementRef<HTMLElement>);
// Input with alias matching selector // Input with alias matching selector
color = input("yellow", { alias: "appHighlight" }); color = input('yellow', { alias: 'appHighlight' });
constructor() { constructor() {
effect(() => { effect(() => {
this.el.nativeElement.style.backgroundColor = this.color(); this.el.nativeElement.style.backgroundColor = this.color();
}); });
} }
} }
// Usage: <p appHighlight="lightblue">Highlighted text</p> // Usage: <p appHighlight="lightblue">Highlighted text</p>
@@ -40,39 +40,39 @@ Prefer `host` over `@HostBinding`/`@HostListener`:
```typescript ```typescript
@Directive({ @Directive({
selector: "[appTooltip]", selector: '[appTooltip]',
host: { host: {
"(mouseenter)": "show()", '(mouseenter)': 'show()',
"(mouseleave)": "hide()", '(mouseleave)': 'hide()',
"[attr.aria-describedby]": "tooltipId", '[attr.aria-describedby]': 'tooltipId',
}, },
}) })
export class Tooltip { export class Tooltip {
text = input.required<string>({ alias: "appTooltip" }); text = input.required<string>({ alias: 'appTooltip' });
position = input<"top" | "bottom" | "left" | "right">("top"); position = input<'top' | 'bottom' | 'left' | 'right'>('top');
tooltipId = `tooltip-${crypto.randomUUID()}`; tooltipId = `tooltip-${crypto.randomUUID()}`;
private tooltipEl: HTMLElement | null = null; private tooltipEl: HTMLElement | null = null;
private el = inject(ElementRef<HTMLElement>); private el = inject(ElementRef<HTMLElement>);
show() { show() {
this.tooltipEl = document.createElement("div"); this.tooltipEl = document.createElement('div');
this.tooltipEl.id = this.tooltipId; this.tooltipEl.id = this.tooltipId;
this.tooltipEl.className = `tooltip tooltip-${this.position()}`; this.tooltipEl.className = `tooltip tooltip-${this.position()}`;
this.tooltipEl.textContent = this.text(); this.tooltipEl.textContent = this.text();
this.tooltipEl.setAttribute("role", "tooltip"); this.tooltipEl.setAttribute('role', 'tooltip');
document.body.appendChild(this.tooltipEl); document.body.appendChild(this.tooltipEl);
this.positionTooltip(); this.positionTooltip();
} }
hide() { hide() {
this.tooltipEl?.remove(); this.tooltipEl?.remove();
this.tooltipEl = null; this.tooltipEl = null;
} }
private positionTooltip() { private positionTooltip() {
// Position logic based on this.position() and this.el // Position logic based on this.position() and this.el
} }
} }
// Usage: <button appTooltip="Click to save" position="bottom">Save</button> // Usage: <button appTooltip="Click to save" position="bottom">Save</button>
@@ -82,21 +82,21 @@ export class Tooltip {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appButton]", selector: '[appButton]',
host: { host: {
class: "btn", 'class': 'btn',
"[class.btn-primary]": 'variant() === "primary"', '[class.btn-primary]': 'variant() === "primary"',
"[class.btn-secondary]": 'variant() === "secondary"', '[class.btn-secondary]': 'variant() === "secondary"',
"[class.btn-sm]": 'size() === "small"', '[class.btn-sm]': 'size() === "small"',
"[class.btn-lg]": 'size() === "large"', '[class.btn-lg]': 'size() === "large"',
"[class.disabled]": "disabled()", '[class.disabled]': 'disabled()',
"[attr.disabled]": "disabled() || null", '[attr.disabled]': 'disabled() || null',
}, },
}) })
export class Button { export class Button {
variant = input<"primary" | "secondary">("primary"); variant = input<'primary' | 'secondary'>('primary');
size = input<"small" | "medium" | "large">("medium"); size = input<'small' | 'medium' | 'large'>('medium');
disabled = input(false, { transform: booleanAttribute }); disabled = input(false, { transform: booleanAttribute });
} }
// Usage: <button appButton variant="primary" size="large">Click</button> // Usage: <button appButton variant="primary" size="large">Click</button>
@@ -106,21 +106,21 @@ export class Button {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appClickOutside]", selector: '[appClickOutside]',
host: { host: {
"(document:click)": "onDocumentClick($event)", '(document:click)': 'onDocumentClick($event)',
}, },
}) })
export class ClickOutside { export class ClickOutside {
private el = inject(ElementRef<HTMLElement>); private el = inject(ElementRef<HTMLElement>);
clickOutside = output<void>(); clickOutside = output<void>();
onDocumentClick(event: MouseEvent) { onDocumentClick(event: MouseEvent) {
if (!this.el.nativeElement.contains(event.target as Node)) { if (!this.el.nativeElement.contains(event.target as Node)) {
this.clickOutside.emit(); this.clickOutside.emit();
}
} }
}
} }
// Usage: <div appClickOutside (clickOutside)="closeMenu()">...</div> // Usage: <div appClickOutside (clickOutside)="closeMenu()">...</div>
@@ -130,32 +130,30 @@ export class ClickOutside {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appShortcut]", selector: '[appShortcut]',
host: { host: {
"(document:keydown)": "onKeydown($event)", '(document:keydown)': 'onKeydown($event)',
}, },
}) })
export class Shortcut { export class Shortcut {
key = input.required<string>({ alias: "appShortcut" }); key = input.required<string>({ alias: 'appShortcut' });
ctrl = input(false, { transform: booleanAttribute }); ctrl = input(false, { transform: booleanAttribute });
shift = input(false, { transform: booleanAttribute }); shift = input(false, { transform: booleanAttribute });
alt = input(false, { transform: booleanAttribute }); alt = input(false, { transform: booleanAttribute });
triggered = output<KeyboardEvent>(); triggered = output<KeyboardEvent>();
onKeydown(event: KeyboardEvent) { onKeydown(event: KeyboardEvent) {
const keyMatch = event.key.toLowerCase() === this.key().toLowerCase(); const keyMatch = event.key.toLowerCase() === this.key().toLowerCase();
const ctrlMatch = this.ctrl() const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
? event.ctrlKey || event.metaKey const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey;
: !event.ctrlKey && !event.metaKey; const altMatch = this.alt() ? event.altKey : !event.altKey;
const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey;
const altMatch = this.alt() ? event.altKey : !event.altKey;
if (keyMatch && ctrlMatch && shiftMatch && altMatch) { if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
event.preventDefault(); event.preventDefault();
this.triggered.emit(event); this.triggered.emit(event);
}
} }
}
} }
// Usage: <button appShortcut="s" ctrl (triggered)="save()">Save (Ctrl+S)</button> // Usage: <button appShortcut="s" ctrl (triggered)="save()">Save (Ctrl+S)</button>
@@ -170,46 +168,38 @@ Use structural directives for DOM manipulation beyond control flow (portals, ove
Render content in a different DOM location: Render content in a different DOM location:
```typescript ```typescript
import { import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core';
Directive,
inject,
TemplateRef,
ViewContainerRef,
OnInit,
OnDestroy,
input,
} from "@angular/core";
@Directive({ @Directive({
selector: "[appPortal]", selector: '[appPortal]',
}) })
export class Portal implements OnInit, OnDestroy { export class Portal implements OnInit, OnDestroy {
private templateRef = inject(TemplateRef<any>); private templateRef = inject(TemplateRef<any>);
private viewContainerRef = inject(ViewContainerRef); private viewContainerRef = inject(ViewContainerRef);
private viewRef: EmbeddedViewRef<any> | null = null; private viewRef: EmbeddedViewRef<any> | null = null;
// Target container selector or element // Target container selector or element
target = input<string | HTMLElement>("body", { alias: "appPortal" }); target = input<string | HTMLElement>('body', { alias: 'appPortal' });
ngOnInit() { ngOnInit() {
const container = this.getContainer(); const container = this.getContainer();
if (container) { if (container) {
this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef); this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
this.viewRef.rootNodes.forEach((node) => container.appendChild(node)); this.viewRef.rootNodes.forEach((node) => container.appendChild(node));
}
} }
}
ngOnDestroy() { ngOnDestroy() {
this.viewRef?.destroy(); this.viewRef?.destroy();
} }
private getContainer(): HTMLElement | null { private getContainer(): HTMLElement | null {
const target = this.target(); const target = this.target();
if (typeof target === "string") { if (typeof target === 'string') {
return document.querySelector(target); return document.querySelector(target);
}
return target;
} }
return target;
}
} }
// Usage: Render modal at body level // Usage: Render modal at body level
@@ -224,24 +214,24 @@ Defer rendering until condition is met (one-time):
```typescript ```typescript
@Directive({ @Directive({
selector: "[appLazyRender]", selector: '[appLazyRender]',
}) })
export class LazyRender { export class LazyRender {
private templateRef = inject(TemplateRef<any>); private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef); private viewContainer = inject(ViewContainerRef);
private rendered = false; private rendered = false;
condition = input.required<boolean>({ alias: "appLazyRender" }); condition = input.required<boolean>({ alias: 'appLazyRender' });
constructor() { constructor() {
effect(() => { effect(() => {
// Only render once when condition becomes true // Only render once when condition becomes true
if (this.condition() && !this.rendered) { if (this.condition() && !this.rendered) {
this.viewContainer.createEmbeddedView(this.templateRef); this.viewContainer.createEmbeddedView(this.templateRef);
this.rendered = true; this.rendered = true;
} }
}); });
} }
} }
// Usage: Render heavy component only when tab is first activated // Usage: Render heavy component only when tab is first activated
@@ -254,44 +244,44 @@ export class LazyRender {
```typescript ```typescript
interface TemplateContext<T> { interface TemplateContext<T> {
$implicit: T; $implicit: T;
item: T; item: T;
index: number; index: number;
} }
@Directive({ @Directive({
selector: "[appTemplateOutlet]", selector: '[appTemplateOutlet]',
}) })
export class TemplateOutlet<T> { export class TemplateOutlet<T> {
private viewContainer = inject(ViewContainerRef); private viewContainer = inject(ViewContainerRef);
private currentView: EmbeddedViewRef<TemplateContext<T>> | null = null; private currentView: EmbeddedViewRef<TemplateContext<T>> | null = null;
template = input.required<TemplateRef<TemplateContext<T>>>({ template = input.required<TemplateRef<TemplateContext<T>>>({
alias: "appTemplateOutlet", 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,
});
}
}); });
} 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 // Usage: Custom list with template
@@ -310,64 +300,64 @@ Compose directives on components or other directives:
```typescript ```typescript
// Reusable behavior directives // Reusable behavior directives
@Directive({ @Directive({
selector: "[focusable]", selector: '[focusable]',
host: { host: {
tabindex: "0", 'tabindex': '0',
"(focus)": "onFocus()", '(focus)': 'onFocus()',
"(blur)": "onBlur()", '(blur)': 'onBlur()',
"[class.focused]": "isFocused()", '[class.focused]': 'isFocused()',
}, },
}) })
export class Focusable { export class Focusable {
isFocused = signal(false); isFocused = signal(false);
onFocus() { onFocus() {
this.isFocused.set(true); this.isFocused.set(true);
} }
onBlur() { onBlur() {
this.isFocused.set(false); this.isFocused.set(false);
} }
} }
@Directive({ @Directive({
selector: "[disableable]", selector: '[disableable]',
host: { host: {
"[class.disabled]": "disabled()", '[class.disabled]': 'disabled()',
"[attr.aria-disabled]": "disabled()", '[attr.aria-disabled]': 'disabled()',
}, },
}) })
export class Disableable { export class Disableable {
disabled = input(false, { transform: booleanAttribute }); disabled = input(false, { transform: booleanAttribute });
} }
// Component using host directives // Component using host directives
@Component({ @Component({
selector: "app-custom-button", selector: 'app-custom-button',
hostDirectives: [ hostDirectives: [
Focusable, Focusable,
{ {
directive: Disableable, directive: Disableable,
inputs: ["disabled"], inputs: ['disabled'],
},
],
host: {
'role': 'button',
'(click)': 'onClick($event)',
'(keydown.enter)': 'onClick($event)',
'(keydown.space)': 'onClick($event)',
}, },
], template: `<ng-content />`,
host: {
role: "button",
"(click)": "onClick($event)",
"(keydown.enter)": "onClick($event)",
"(keydown.space)": "onClick($event)",
},
template: `<ng-content />`,
}) })
export class CustomButton { export class CustomButton {
private disableable = inject(Disableable); private disableable = inject(Disableable);
clicked = output<void>(); clicked = output<void>();
onClick(event: Event) { onClick(event: Event) {
if (!this.disableable.disabled()) { if (!this.disableable.disabled()) {
this.clicked.emit(); this.clicked.emit();
}
} }
}
} }
// Usage: <app-custom-button disabled>Click me</app-custom-button> // Usage: <app-custom-button disabled>Click me</app-custom-button>
@@ -377,38 +367,38 @@ export class CustomButton {
```typescript ```typescript
@Directive({ @Directive({
selector: "[hoverable]", selector: '[hoverable]',
host: { host: {
"(mouseenter)": "onEnter()", '(mouseenter)': 'onEnter()',
"(mouseleave)": "onLeave()", '(mouseleave)': 'onLeave()',
"[class.hovered]": "isHovered()", '[class.hovered]': 'isHovered()',
}, },
}) })
export class Hoverable { export class Hoverable {
isHovered = signal(false); isHovered = signal(false);
hoverChange = output<boolean>(); hoverChange = output<boolean>();
onEnter() { onEnter() {
this.isHovered.set(true); this.isHovered.set(true);
this.hoverChange.emit(true); this.hoverChange.emit(true);
} }
onLeave() { onLeave() {
this.isHovered.set(false); this.isHovered.set(false);
this.hoverChange.emit(false); this.hoverChange.emit(false);
} }
} }
@Component({ @Component({
selector: "app-card", selector: 'app-card',
hostDirectives: [ hostDirectives: [
{ {
directive: Hoverable, directive: Hoverable,
outputs: ["hoverChange"], outputs: ['hoverChange'],
}, },
], ],
template: `<ng-content />`, template: `<ng-content />`,
}) })
export class Card {} export class Card {}
@@ -421,31 +411,31 @@ Combine multiple behaviors:
```typescript ```typescript
// Base directives // Base directives
@Directive({ selector: "[withRipple]" }) @Directive({ selector: '[withRipple]' })
export class Ripple { export class Ripple {
// Ripple effect implementation // Ripple effect implementation
} }
@Directive({ selector: "[withElevation]" }) @Directive({ selector: '[withElevation]' })
export class Elevation { export class Elevation {
elevation = input(2); elevation = input(2);
} }
// Composed component // Composed component
@Component({ @Component({
selector: "app-material-button", selector: 'app-material-button',
hostDirectives: [ hostDirectives: [
Ripple, Ripple,
{ {
directive: Elevation, directive: Elevation,
inputs: ["elevation"], inputs: ['elevation'],
}, },
{ {
directive: Disableable, directive: Disableable,
inputs: ["disabled"], inputs: ['disabled'],
}, },
], ],
template: `<ng-content />`, template: `<ng-content />`,
}) })
export class MaterialButton {} export class MaterialButton {}
``` ```
@@ -15,23 +15,23 @@
```typescript ```typescript
@Directive({ @Directive({
selector: "[appAutoFocus]", selector: '[appAutoFocus]',
}) })
export class AutoFocus { export class AutoFocus {
private el = inject(ElementRef<HTMLElement>); private el = inject(ElementRef<HTMLElement>);
enabled = input(true, { alias: "appAutoFocus", transform: booleanAttribute }); enabled = input(true, { alias: 'appAutoFocus', transform: booleanAttribute });
delay = input(0); delay = input(0);
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
if (this.enabled()) { if (this.enabled()) {
setTimeout(() => { setTimeout(() => {
this.el.nativeElement.focus(); this.el.nativeElement.focus();
}, this.delay()); }, this.delay());
} }
}); });
} }
} }
// Usage: <input appAutoFocus /> // Usage: <input appAutoFocus />
@@ -42,26 +42,26 @@ export class AutoFocus {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appSelectAll]", selector: '[appSelectAll]',
host: { host: {
"(focus)": "onFocus()", '(focus)': 'onFocus()',
"(click)": "onClick($event)", '(click)': 'onClick($event)',
}, },
}) })
export class SelectAll { export class SelectAll {
private el = inject(ElementRef<HTMLInputElement>); private el = inject(ElementRef<HTMLInputElement>);
onFocus() { onFocus() {
// Delay to ensure value is set // Delay to ensure value is set
setTimeout(() => this.el.nativeElement.select(), 0); setTimeout(() => this.el.nativeElement.select(), 0);
} }
onClick(event: MouseEvent) { onClick(event: MouseEvent) {
// Select all on first click if not already focused // Select all on first click if not already focused
if (document.activeElement !== this.el.nativeElement) { if (document.activeElement !== this.el.nativeElement) {
this.el.nativeElement.select(); this.el.nativeElement.select();
}
} }
}
} }
// Usage: <input appSelectAll value="Select me on focus" /> // Usage: <input appSelectAll value="Select me on focus" />
@@ -71,26 +71,26 @@ export class SelectAll {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appCopyToClipboard]", selector: '[appCopyToClipboard]',
host: { host: {
"(click)": "copy()", '(click)': 'copy()',
"[style.cursor]": '"pointer"', '[style.cursor]': '"pointer"',
}, },
}) })
export class CopyToClipboard { export class CopyToClipboard {
text = input.required<string>({ alias: "appCopyToClipboard" }); text = input.required<string>({ alias: 'appCopyToClipboard' });
copied = output<void>(); copied = output<void>();
error = output<Error>(); error = output<Error>();
async copy() { async copy() {
try { try {
await navigator.clipboard.writeText(this.text()); await navigator.clipboard.writeText(this.text());
this.copied.emit(); this.copied.emit();
} catch (err) { } catch (err) {
this.error.emit(err as Error); this.error.emit(err as Error);
}
} }
}
} }
// Usage: // Usage:
@@ -105,24 +105,24 @@ export class CopyToClipboard {
```typescript ```typescript
@Directive({ @Directive({
selector: "input[appTrim], textarea[appTrim]", selector: 'input[appTrim], textarea[appTrim]',
host: { host: {
"(blur)": "onBlur()", '(blur)': 'onBlur()',
}, },
}) })
export class Trim { export class Trim {
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>); private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
private ngControl = inject(NgControl, { optional: true, self: true }); private ngControl = inject(NgControl, { optional: true, self: true });
onBlur() { onBlur() {
const value = this.el.nativeElement.value; const value = this.el.nativeElement.value;
const trimmed = value.trim(); const trimmed = value.trim();
if (value !== trimmed) { if (value !== trimmed) {
this.el.nativeElement.value = trimmed; this.el.nativeElement.value = trimmed;
this.ngControl?.control?.setValue(trimmed); this.ngControl?.control?.setValue(trimmed);
}
} }
}
} }
// Usage: <input appTrim formControlName="name" /> // Usage: <input appTrim formControlName="name" />
@@ -132,92 +132,88 @@ export class Trim {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appMask]", selector: '[appMask]',
host: { host: {
"(input)": "onInput($event)", '(input)': 'onInput($event)',
"(keydown)": "onKeydown($event)", '(keydown)': 'onKeydown($event)',
}, },
}) })
export class Mask { export class Mask {
private el = inject(ElementRef<HTMLInputElement>); private el = inject(ElementRef<HTMLInputElement>);
// Mask pattern: 9 = digit, A = letter, * = any // Mask pattern: 9 = digit, A = letter, * = any
mask = input.required<string>({ alias: "appMask" }); mask = input.required<string>({ alias: 'appMask' });
onInput(event: InputEvent) { onInput(event: InputEvent) {
const input = this.el.nativeElement; const input = this.el.nativeElement;
const value = input.value; const value = input.value;
const masked = this.applyMask(value); const masked = this.applyMask(value);
if (value !== masked) { if (value !== masked) {
input.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) { onKeydown(event: KeyboardEvent) {
valueIndex++; // 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();
} }
}
} }
return result; private applyMask(value: string): string {
} const mask = this.mask();
let result = '';
let valueIndex = 0;
private isValidChar(char: string, maskChar: string): boolean { for (let i = 0; i < mask.length && valueIndex < value.length; i++) {
switch (maskChar) { const maskChar = mask[i];
case "9": const inputChar = value[valueIndex];
return /\d/.test(char);
case "A": if (maskChar === '9' || maskChar === 'A' || maskChar === '*') {
return /[a-zA-Z]/.test(char); if (this.isValidChar(inputChar, maskChar)) {
case "*": result += inputChar;
return /[a-zA-Z0-9]/.test(char); valueIndex++;
default: } else {
return char === maskChar; 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" /> // Usage: <input appMask="(999) 999-9999" placeholder="(555) 123-4567" />
@@ -227,29 +223,29 @@ export class Mask {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appCharCount]", selector: '[appCharCount]',
}) })
export class CharCount { export class CharCount {
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>); private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
maxLength = input.required<number>({ alias: "appCharCount" }); maxLength = input.required<number>({ alias: 'appCharCount' });
currentLength = signal(0); currentLength = signal(0);
remaining = computed(() => this.maxLength() - this.currentLength()); remaining = computed(() => this.maxLength() - this.currentLength());
isOverLimit = computed(() => this.remaining() < 0); isOverLimit = computed(() => this.remaining() < 0);
constructor() { constructor() {
effect(() => { effect(() => {
this.currentLength.set(this.el.nativeElement.value.length); this.currentLength.set(this.el.nativeElement.value.length);
}); });
// Listen for input changes // Listen for input changes
afterNextRender(() => { afterNextRender(() => {
this.el.nativeElement.addEventListener("input", () => { this.el.nativeElement.addEventListener('input', () => {
this.currentLength.set(this.el.nativeElement.value.length); this.currentLength.set(this.el.nativeElement.value.length);
}); });
}); });
} }
} }
// Usage with template: // Usage with template:
@@ -263,59 +259,59 @@ export class CharCount {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appLazyLoad]", selector: '[appLazyLoad]',
}) })
export class LazyLoad implements OnDestroy { export class LazyLoad implements OnDestroy {
private el = inject(ElementRef<HTMLElement>); private el = inject(ElementRef<HTMLElement>);
private observer: IntersectionObserver | null = null; private observer: IntersectionObserver | null = null;
src = input.required<string>({ alias: "appLazyLoad" }); src = input.required<string>({ alias: 'appLazyLoad' });
placeholder = input("/assets/placeholder.png"); placeholder = input('/assets/placeholder.png');
loaded = output<void>(); loaded = output<void>();
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
this.setupObserver(); 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() { private setupObserver() {
const element = this.el.nativeElement; this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage();
this.observer?.disconnect();
}
});
},
{ rootMargin: '50px' },
);
if (element instanceof HTMLImageElement) { this.observer.observe(this.el.nativeElement);
element.src = this.src();
element.onload = () => this.loaded.emit(); // Set placeholder
} else { if (this.el.nativeElement instanceof HTMLImageElement) {
element.style.backgroundImage = `url(${this.src()})`; this.el.nativeElement.src = this.placeholder();
this.loaded.emit(); }
} }
}
ngOnDestroy() { private loadImage() {
this.observer?.disconnect(); 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" /> // Usage: <img [appLazyLoad]="imageUrl" alt="Lazy loaded image" />
@@ -325,49 +321,49 @@ export class LazyLoad implements OnDestroy {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appInfiniteScroll]", selector: '[appInfiniteScroll]',
}) })
export class InfiniteScroll implements OnDestroy { export class InfiniteScroll implements OnDestroy {
private el = inject(ElementRef<HTMLElement>); private el = inject(ElementRef<HTMLElement>);
private observer: IntersectionObserver | null = null; private observer: IntersectionObserver | null = null;
threshold = input(0.1); threshold = input(0.1);
disabled = input(false); disabled = input(false);
scrolled = output<void>(); scrolled = output<void>();
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
this.setupObserver(); this.setupObserver();
}); });
effect(() => { effect(() => {
if (this.disabled()) { if (this.disabled()) {
this.observer?.disconnect();
} else {
this.setupObserver();
}
});
}
private setupObserver() {
this.observer?.disconnect(); this.observer?.disconnect();
} else {
this.setupObserver();
}
});
}
private setupObserver() { this.observer = new IntersectionObserver(
this.observer?.disconnect(); (entries) => {
if (entries[0].isIntersecting && !this.disabled()) {
this.scrolled.emit();
}
},
{ threshold: this.threshold() },
);
this.observer = new IntersectionObserver( this.observer.observe(this.el.nativeElement);
(entries) => { }
if (entries[0].isIntersecting && !this.disabled()) {
this.scrolled.emit();
}
},
{ threshold: this.threshold() },
);
this.observer.observe(this.el.nativeElement); ngOnDestroy() {
} this.observer?.disconnect();
}
ngOnDestroy() {
this.observer?.disconnect();
}
} }
// Usage: // Usage:
@@ -385,35 +381,35 @@ export class InfiniteScroll implements OnDestroy {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appResize]", selector: '[appResize]',
}) })
export class Resize implements OnDestroy { export class Resize implements OnDestroy {
private el = inject(ElementRef<HTMLElement>); private el = inject(ElementRef<HTMLElement>);
private observer: ResizeObserver | null = null; private observer: ResizeObserver | null = null;
width = signal(0); width = signal(0);
height = signal(0); height = signal(0);
resized = output<{ width: number; height: number }>(); resized = output<{ width: number; height: number }>();
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
this.observer = new ResizeObserver((entries) => { this.observer = new ResizeObserver((entries) => {
const entry = entries[0]; const entry = entries[0];
const { width, height } = entry.contentRect; const { width, height } = entry.contentRect;
this.width.set(width); this.width.set(width);
this.height.set(height); this.height.set(height);
this.resized.emit({ width, height }); this.resized.emit({ width, height });
}); });
this.observer.observe(this.el.nativeElement); this.observer.observe(this.el.nativeElement);
}); });
} }
ngOnDestroy() { ngOnDestroy() {
this.observer?.disconnect(); this.observer?.disconnect();
} }
} }
// Usage: // Usage:
@@ -426,75 +422,72 @@ export class Resize implements OnDestroy {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appDraggable]", selector: '[appDraggable]',
host: { host: {
draggable: "true", 'draggable': 'true',
"[class.dragging]": "isDragging()", '[class.dragging]': 'isDragging()',
"(dragstart)": "onDragStart($event)", '(dragstart)': 'onDragStart($event)',
"(dragend)": "onDragEnd($event)", '(dragend)': 'onDragEnd($event)',
}, },
}) })
export class Draggable { export class Draggable {
data = input<any>(null, { alias: "appDraggable" }); data = input<any>(null, { alias: 'appDraggable' });
effectAllowed = input<DataTransfer["effectAllowed"]>("move"); effectAllowed = input<DataTransfer['effectAllowed']>('move');
isDragging = signal(false); isDragging = signal(false);
dragStart = output<DragEvent>(); dragStart = output<DragEvent>();
dragEnd = output<DragEvent>(); dragEnd = output<DragEvent>();
onDragStart(event: DragEvent) { onDragStart(event: DragEvent) {
this.isDragging.set(true); this.isDragging.set(true);
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = this.effectAllowed(); event.dataTransfer.effectAllowed = this.effectAllowed();
event.dataTransfer.setData( event.dataTransfer.setData('application/json', JSON.stringify(this.data()));
"application/json", }
JSON.stringify(this.data()),
); this.dragStart.emit(event);
} }
this.dragStart.emit(event); onDragEnd(event: DragEvent) {
} this.isDragging.set(false);
this.dragEnd.emit(event);
onDragEnd(event: DragEvent) { }
this.isDragging.set(false);
this.dragEnd.emit(event);
}
} }
@Directive({ @Directive({
selector: "[appDropZone]", selector: '[appDropZone]',
host: { host: {
"[class.drag-over]": "isDragOver()", '[class.drag-over]': 'isDragOver()',
"(dragover)": "onDragOver($event)", '(dragover)': 'onDragOver($event)',
"(dragleave)": "onDragLeave($event)", '(dragleave)': 'onDragLeave($event)',
"(drop)": "onDrop($event)", '(drop)': 'onDrop($event)',
}, },
}) })
export class DropZone { export class DropZone {
isDragOver = signal(false); isDragOver = signal(false);
dropped = output<any>(); dropped = output<any>();
onDragOver(event: DragEvent) { onDragOver(event: DragEvent) {
event.preventDefault(); event.preventDefault();
this.isDragOver.set(true); this.isDragOver.set(true);
} }
onDragLeave(event: DragEvent) { onDragLeave(event: DragEvent) {
this.isDragOver.set(false); this.isDragOver.set(false);
} }
onDrop(event: DragEvent) { onDrop(event: DragEvent) {
event.preventDefault(); event.preventDefault();
this.isDragOver.set(false); this.isDragOver.set(false);
const data = event.dataTransfer?.getData("application/json"); const data = event.dataTransfer?.getData('application/json');
if (data) { if (data) {
this.dropped.emit(JSON.parse(data)); this.dropped.emit(JSON.parse(data));
}
} }
}
} }
// Usage: // Usage:
@@ -506,42 +499,42 @@ export class DropZone {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appHasPermission]", selector: '[appHasPermission]',
}) })
export class HasPermission { export class HasPermission {
private templateRef = inject(TemplateRef<any>); private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef); private viewContainer = inject(ViewContainerRef);
private authService = inject(Auth); private authService = inject(Auth);
private hasView = false; private hasView = false;
permission = input.required<string | string[]>({ alias: "appHasPermission" }); permission = input.required<string | string[]>({ alias: 'appHasPermission' });
mode = input<"any" | "all">("any"); mode = input<'any' | 'all'>('any');
constructor() { constructor() {
effect(() => { effect(() => {
const hasPermission = this.checkPermission(); const hasPermission = this.checkPermission();
if (hasPermission && !this.hasView) { if (hasPermission && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef); this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true; this.hasView = true;
} else if (!hasPermission && this.hasView) { } else if (!hasPermission && this.hasView) {
this.viewContainer.clear(); this.viewContainer.clear();
this.hasView = false; 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)); 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: // Usage:
@@ -553,23 +546,23 @@ export class HasPermission {
```typescript ```typescript
@Directive({ @Directive({
selector: "[appToggle]", selector: '[appToggle]',
exportAs: "appToggle", exportAs: 'appToggle',
}) })
export class Toggle { export class Toggle {
isOpen = signal(false); isOpen = signal(false);
toggle() { toggle() {
this.isOpen.update((v) => !v); this.isOpen.update((v) => !v);
} }
open() { open() {
this.isOpen.set(true); this.isOpen.set(true);
} }
close() { close() {
this.isOpen.set(false); this.isOpen.set(false);
} }
} }
// Usage: // Usage:
+200 -189
View File
@@ -12,60 +12,68 @@ Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms p
## Basic Setup ## Basic Setup
```typescript ```typescript
import { Component, signal } from "@angular/core"; import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from "@angular/forms/signals"; import { form, FormField, required, email } from '@angular/forms/signals';
interface LoginData { interface LoginData {
email: string; email: string;
password: string; password: string;
} }
@Component({ @Component({
selector: "app-login", selector: 'app-login',
imports: [FormField], imports: [FormField],
template: ` template: `
<form (submit)="onSubmit($event)"> <form (submit)="onSubmit($event)">
<label> <label>
Email Email
<input type="email" [formField]="loginForm.email" /> <input
</label> type="email"
@if (loginForm.email().touched() && loginForm.email().invalid()) { [formField]="loginForm.email" />
<p class="error">{{ loginForm.email().errors()[0].message }}</p> </label>
} @if (loginForm.email().touched() && loginForm.email().invalid()) {
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
}
<label> <label>
Password Password
<input type="password" [formField]="loginForm.password" /> <input
</label> type="password"
@if (loginForm.password().touched() && loginForm.password().invalid()) { [formField]="loginForm.password" />
<p class="error">{{ loginForm.password().errors()[0].message }}</p> </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> <button
</form> type="submit"
`, [disabled]="loginForm().invalid()">
Login
</button>
</form>
`,
}) })
export class Login { export class Login {
// Form model - a writable signal // Form model - a writable signal
loginModel = signal<LoginData>({ loginModel = signal<LoginData>({
email: "", email: '',
password: "", password: '',
}); });
// Create form with validation schema // Create form with validation schema
loginForm = form(this.loginModel, (schemaPath) => { loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email, { message: "Email is required" }); required(schemaPath.email, { message: 'Email is required' });
email(schemaPath.email, { message: "Enter a valid email address" }); email(schemaPath.email, { message: 'Enter a valid email address' });
required(schemaPath.password, { message: "Password is required" }); required(schemaPath.password, { message: 'Password is required' });
}); });
onSubmit(event: Event) { onSubmit(event: Event) {
event.preventDefault(); event.preventDefault();
if (this.loginForm().valid()) { if (this.loginForm().valid()) {
const credentials = this.loginModel(); const credentials = this.loginModel();
console.log("Submitting:", credentials); console.log('Submitting:', credentials);
}
} }
}
} }
``` ```
@@ -76,24 +84,24 @@ Form models are writable signals that serve as the single source of truth:
```typescript ```typescript
// Define interface for type safety // Define interface for type safety
interface UserProfile { interface UserProfile {
name: string; name: string;
email: string; email: string;
age: number | null; age: number | null;
preferences: { preferences: {
newsletter: boolean; newsletter: boolean;
theme: "light" | "dark"; theme: 'light' | 'dark';
}; };
} }
// Create model signal with initial values // Create model signal with initial values
const userModel = signal<UserProfile>({ const userModel = signal<UserProfile>({
name: "", name: '',
email: "", email: '',
age: null, age: null,
preferences: { preferences: {
newsletter: false, newsletter: false,
theme: "light", theme: 'light',
}, },
}); });
// Create form from model // Create form from model
@@ -120,14 +128,14 @@ const theme = this.userForm.preferences.theme().value();
```typescript ```typescript
// Replace entire model // Replace entire model
this.userModel.set({ this.userModel.set({
name: "Alice", name: 'Alice',
email: "alice@example.com", email: 'alice@example.com',
age: 30, age: 30,
preferences: { newsletter: true, theme: "dark" }, preferences: { newsletter: true, theme: 'dark' },
}); });
// Update single field // Update single field
this.userForm.name().value.set("Bob"); this.userForm.name().value.set('Bob');
this.userForm.age().value.update((age) => (age ?? 0) + 1); this.userForm.age().value.update((age) => (age ?? 0) + 1);
``` ```
@@ -177,36 +185,27 @@ this.form().dirty();
### Built-in Validators ### Built-in Validators
```typescript ```typescript
import { import { form, required, email, min, max, minLength, maxLength, pattern } from '@angular/forms/signals';
form,
required,
email,
min,
max,
minLength,
maxLength,
pattern,
} from "@angular/forms/signals";
const userForm = form(this.userModel, (schemaPath) => { const userForm = form(this.userModel, (schemaPath) => {
// Required field // Required field
required(schemaPath.name, { message: "Name is required" }); required(schemaPath.name, { message: 'Name is required' });
// Email format // Email format
email(schemaPath.email, { message: "Invalid email" }); email(schemaPath.email, { message: 'Invalid email' });
// Numeric range // Numeric range
min(schemaPath.age, 18, { message: "Must be 18+" }); min(schemaPath.age, 18, { message: 'Must be 18+' });
max(schemaPath.age, 120, { message: "Invalid age" }); max(schemaPath.age, 120, { message: 'Invalid age' });
// String/array length // String/array length
minLength(schemaPath.password, 8, { message: "Min 8 characters" }); minLength(schemaPath.password, 8, { message: 'Min 8 characters' });
maxLength(schemaPath.bio, 500, { message: "Max 500 characters" }); maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });
// Regex pattern // Regex pattern
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, { pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
message: "Format: 555-123-4567", message: 'Format: 555-123-4567',
}); });
}); });
``` ```
@@ -214,26 +213,26 @@ const userForm = form(this.userModel, (schemaPath) => {
```typescript ```typescript
const orderForm = form(this.orderModel, (schemaPath) => { const orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.promoCode, { required(schemaPath.promoCode, {
message: "Promo code required for discounts", message: 'Promo code required for discounts',
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount), when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
}); });
}); });
``` ```
### Custom Validators ### Custom Validators
```typescript ```typescript
import { validate } from "@angular/forms/signals"; import { validate } from '@angular/forms/signals';
const signupForm = form(this.signupModel, (schemaPath) => { const signupForm = form(this.signupModel, (schemaPath) => {
// Custom validation logic // Custom validation logic
validate(schemaPath.username, ({ value }) => { validate(schemaPath.username, ({ value }) => {
if (value().includes(" ")) { if (value().includes(' ')) {
return { kind: "noSpaces", message: "Username cannot contain spaces" }; return { kind: 'noSpaces', message: 'Username cannot contain spaces' };
} }
return null; return null;
}); });
}); });
``` ```
@@ -241,38 +240,38 @@ const signupForm = form(this.signupModel, (schemaPath) => {
```typescript ```typescript
const passwordForm = form(this.passwordModel, (schemaPath) => { const passwordForm = form(this.passwordModel, (schemaPath) => {
required(schemaPath.password); required(schemaPath.password);
required(schemaPath.confirmPassword); required(schemaPath.confirmPassword);
// Compare fields // Compare fields
validate(schemaPath.confirmPassword, ({ value, valueOf }) => { validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(schemaPath.password)) { if (value() !== valueOf(schemaPath.password)) {
return { kind: "mismatch", message: "Passwords do not match" }; return { kind: 'mismatch', message: 'Passwords do not match' };
} }
return null; return null;
}); });
}); });
``` ```
### Async Validation ### Async Validation
```typescript ```typescript
import { validateHttp } from "@angular/forms/signals"; import { validateHttp } from '@angular/forms/signals';
const signupForm = form(this.signupModel, (schemaPath) => { const signupForm = form(this.signupModel, (schemaPath) => {
validateHttp(schemaPath.username, { validateHttp(schemaPath.username, {
request: ({ value }) => `/api/check-username?u=${value()}`, request: ({ value }) => `/api/check-username?u=${value()}`,
onSuccess: (response: { taken: boolean }) => { onSuccess: (response: { taken: boolean }) => {
if (response.taken) { if (response.taken) {
return { kind: "taken", message: "Username already taken" }; return { kind: 'taken', message: 'Username already taken' };
} }
return null; return null;
}, },
onError: () => ({ onError: () => ({
kind: "networkError", kind: 'networkError',
message: "Could not verify username", message: 'Could not verify username',
}), }),
}); });
}); });
``` ```
@@ -281,10 +280,10 @@ const signupForm = form(this.signupModel, (schemaPath) => {
### Hidden Fields ### Hidden Fields
```typescript ```typescript
import { hidden } from "@angular/forms/signals"; import { hidden } from '@angular/forms/signals';
const profileForm = form(this.profileModel, (schemaPath) => { const profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic)); hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
}); });
``` ```
@@ -297,55 +296,56 @@ const profileForm = form(this.profileModel, (schemaPath) => {
### Disabled Fields ### Disabled Fields
```typescript ```typescript
import { disabled } from "@angular/forms/signals"; import { disabled } from '@angular/forms/signals';
const orderForm = form(this.orderModel, (schemaPath) => { const orderForm = form(this.orderModel, (schemaPath) => {
disabled( disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
schemaPath.couponCode,
({ valueOf }) => valueOf(schemaPath.total) < 50,
);
}); });
``` ```
### Readonly Fields ### Readonly Fields
```typescript ```typescript
import { readonly } from "@angular/forms/signals"; import { readonly } from '@angular/forms/signals';
const accountForm = form(this.accountModel, (schemaPath) => { const accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username); // Always readonly readonly(schemaPath.username); // Always readonly
}); });
``` ```
## Form Submission ## Form Submission
```typescript ```typescript
import { submit } from "@angular/forms/signals"; import { submit } from '@angular/forms/signals';
@Component({ @Component({
template: ` template: `
<form (submit)="onSubmit($event)"> <form (submit)="onSubmit($event)">
<input [formField]="form.email" /> <input [formField]="form.email" />
<input [formField]="form.password" /> <input [formField]="form.password" />
<button type="submit" [disabled]="form().invalid()">Submit</button> <button
</form> type="submit"
`, [disabled]="form().invalid()">
Submit
</button>
</form>
`,
}) })
export class Login { export class Login {
model = signal({ email: "", password: "" }); model = signal({ email: '', password: '' });
form = form(this.model, (schemaPath) => { form = form(this.model, (schemaPath) => {
required(schemaPath.email); required(schemaPath.email);
required(schemaPath.password); 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());
}); });
}
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());
});
}
} }
``` ```
@@ -353,46 +353,58 @@ export class Login {
```typescript ```typescript
interface Order { interface Order {
items: Array<{ product: string; quantity: number }>; items: Array<{ product: string; quantity: number }>;
} }
@Component({ @Component({
template: ` template: `
@for (item of orderForm.items; track $index; let i = $index) { @for (item of orderForm.items; track $index; let i = $index) {
<div> <div>
<input [formField]="item.product" placeholder="Product" /> <input
<input [formField]="item.quantity" type="number" /> placeholder="Product"
<button type="button" (click)="removeItem(i)">Remove</button> [formField]="item.product" />
</div> <input
} type="number"
<button type="button" (click)="addItem()">Add Item</button> [formField]="item.quantity" />
`, <button
type="button"
(click)="removeItem(i)">
Remove
</button>
</div>
}
<button
type="button"
(click)="addItem()">
Add Item
</button>
`,
}) })
export class Order { export class Order {
orderModel = signal<Order>({ orderModel = signal<Order>({
items: [{ product: "", quantity: 1 }], 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() { orderForm = form(this.orderModel, (schemaPath) => {
this.orderModel.update((m) => ({ applyEach(schemaPath.items, (item) => {
...m, required(item.product, { message: 'Product required' });
items: [...m.items, { product: "", quantity: 1 }], min(item.quantity, 1, { message: 'Min quantity is 1' });
})); });
} });
removeItem(index: number) { addItem() {
this.orderModel.update((m) => ({ this.orderModel.update((m) => ({
...m, ...m,
items: m.items.filter((_, i) => i !== index), items: [...m.items, { product: '', quantity: 1 }],
})); }));
} }
removeItem(index: number) {
this.orderModel.update((m) => ({
...m,
items: m.items.filter((_, i) => i !== index),
}));
}
} }
``` ```
@@ -403,9 +415,9 @@ export class Order {
@if (form.email().touched() && form.email().invalid()) { @if (form.email().touched() && form.email().invalid()) {
<ul class="errors"> <ul class="errors">
@for (error of form.email().errors(); track error) { @for (error of form.email().errors(); track error) {
<li>{{ error.message }}</li> <li>{{ error.message }}</li>
} }
</ul> </ul>
} @if (form.email().pending()) { } @if (form.email().pending()) {
<span>Validating...</span> <span>Validating...</span>
@@ -416,10 +428,9 @@ export class Order {
```html ```html
<input <input
[formField]="form.email" [formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()" [class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()" [class.is-valid]="form.email().touched() && form.email().valid()" />
/>
``` ```
## Reset Form ## Reset Form
@@ -14,40 +14,46 @@
For production applications requiring stability guarantees, use Reactive Forms: For production applications requiring stability guarantees, use Reactive Forms:
```typescript ```typescript
import { Component, inject } from "@angular/core"; import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms"; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({ @Component({
selector: "app-login", selector: 'app-login',
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule],
template: ` template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <form
<input formControlName="email" /> [formGroup]="form"
@if ( (ngSubmit)="onSubmit()">
form.controls.email.errors?.["required"] && form.controls.email.touched <input formControlName="email" />
) { @if (form.controls.email.errors?.['required'] && form.controls.email.touched) {
<span class="error">Email is required</span> <span class="error">Email is required</span>
} }
<input type="password" formControlName="password" /> <input
type="password"
formControlName="password" />
<button type="submit" [disabled]="form.invalid">Login</button> <button
</form> type="submit"
`, [disabled]="form.invalid">
Login
</button>
</form>
`,
}) })
export class Login { export class Login {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
form = this.fb.group({ form = this.fb.group({
email: ["", [Validators.required, Validators.email]], email: ['', [Validators.required, Validators.email]],
password: ["", [Validators.required, Validators.minLength(8)]], password: ['', [Validators.required, Validators.minLength(8)]],
}); });
onSubmit() { onSubmit() {
if (this.form.valid) { if (this.form.valid) {
console.log(this.form.value); console.log(this.form.value);
}
} }
}
} }
``` ```
@@ -56,37 +62,37 @@ export class Login {
### Typed FormControl ### Typed FormControl
```typescript ```typescript
import { FormControl } from "@angular/forms"; import { FormControl } from '@angular/forms';
// Inferred type: FormControl<string | null> // Inferred type: FormControl<string | null>
const name = new FormControl(""); const name = new FormControl('');
// Non-nullable (no reset to null) // Non-nullable (no reset to null)
const email = new FormControl("", { nonNullable: true }); const email = new FormControl('', { nonNullable: true });
// Type: FormControl<string> // Type: FormControl<string>
// With validators // With validators
const username = new FormControl("", { const username = new FormControl('', {
nonNullable: true, nonNullable: true,
validators: [Validators.required, Validators.minLength(3)], validators: [Validators.required, Validators.minLength(3)],
}); });
``` ```
### Typed FormGroup ### Typed FormGroup
```typescript ```typescript
import { FormGroup, FormControl } from "@angular/forms"; import { FormGroup, FormControl } from '@angular/forms';
interface UserForm { interface UserForm {
name: FormControl<string>; name: FormControl<string>;
email: FormControl<string>; email: FormControl<string>;
age: FormControl<number | null>; age: FormControl<number | null>;
} }
const form = new FormGroup<UserForm>({ const form = new FormGroup<UserForm>({
name: new FormControl("", { nonNullable: true }), name: new FormControl('', { nonNullable: true }),
email: new FormControl("", { nonNullable: true }), email: new FormControl('', { nonNullable: true }),
age: new FormControl<number | null>(null), age: new FormControl<number | null>(null),
}); });
// Typed value access // Typed value access
@@ -120,82 +126,104 @@ export class Profile {
```typescript ```typescript
@Component({ @Component({
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule],
template: ` template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <form
<input formControlName="name" placeholder="Name" /> [formGroup]="form"
(ngSubmit)="onSubmit()">
<input
formControlName="name"
placeholder="Name" />
<div formGroupName="address"> <div formGroupName="address">
<input formControlName="street" placeholder="Street" /> <input
<input formControlName="city" placeholder="City" /> formControlName="street"
<input formControlName="zip" placeholder="ZIP" /> placeholder="Street" />
</div> <input
formControlName="city"
placeholder="City" />
<input
formControlName="zip"
placeholder="ZIP" />
</div>
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>
`, `,
}) })
export class Profile { export class Profile {
private fb = inject(NonNullableFormBuilder); private fb = inject(NonNullableFormBuilder);
form = this.fb.group({ form = this.fb.group({
name: ["", Validators.required], name: ['', Validators.required],
address: this.fb.group({ address: this.fb.group({
street: [""], street: [''],
city: ["", Validators.required], city: ['', Validators.required],
zip: ["", [Validators.required, Validators.pattern(/^\d{5}$/)]], zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
}), }),
}); });
} }
``` ```
## Dynamic Forms with FormArray ## Dynamic Forms with FormArray
```typescript ```typescript
import { FormArray } from "@angular/forms"; import { FormArray } from '@angular/forms';
@Component({ @Component({
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule],
template: ` template: `
<form [formGroup]="form"> <form [formGroup]="form">
<div formArrayName="items"> <div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) { @for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i"> <div [formGroupName]="i">
<input formControlName="product" placeholder="Product" /> <input
<input formControlName="quantity" type="number" /> formControlName="product"
<button type="button" (click)="removeItem(i)">Remove</button> placeholder="Product" />
</div> <input
} formControlName="quantity"
</div> type="number" />
<button type="button" (click)="addItem()">Add Item</button> <button
</form> type="button"
`, (click)="removeItem(i)">
Remove
</button>
</div>
}
</div>
<button
type="button"
(click)="addItem()">
Add Item
</button>
</form>
`,
}) })
export class Order { export class Order {
private fb = inject(NonNullableFormBuilder); private fb = inject(NonNullableFormBuilder);
form = this.fb.group({ form = this.fb.group({
items: this.fb.array([this.createItem()]), 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() { get items() {
this.items.push(this.createItem()); return this.form.controls.items;
} }
removeItem(index: number) { createItem() {
this.items.removeAt(index); 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);
}
} }
``` ```
@@ -222,20 +250,20 @@ name: ['', [Validators.required, forbiddenValue('admin')]],
```typescript ```typescript
export function passwordMatch(): ValidatorFn { export function passwordMatch(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => { return (group: AbstractControl): ValidationErrors | null => {
const password = group.get("password")?.value; const password = group.get('password')?.value;
const confirm = group.get("confirmPassword")?.value; const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true }; return password === confirm ? null : { passwordMismatch: true };
}; };
} }
// Usage // Usage
form = this.fb.group( form = this.fb.group(
{ {
password: ["", [Validators.required, Validators.minLength(8)]], password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ["", Validators.required], confirmPassword: ['', Validators.required],
}, },
{ validators: passwordMatch() }, { validators: passwordMatch() },
); );
``` ```
@@ -276,12 +304,12 @@ form.touched; // Control has been focused
form.untouched; // Control never focused form.untouched; // Control never focused
// Update values // Update values
form.setValue({ name: "John", email: "john@example.com" }); // Must include all form.setValue({ name: 'John', email: 'john@example.com' }); // Must include all
form.patchValue({ name: "John" }); // Partial update form.patchValue({ name: 'John' }); // Partial update
// Reset // Reset
form.reset(); form.reset();
form.reset({ name: "Default" }); form.reset({ name: 'Default' });
// Disable/Enable // Disable/Enable
form.disable(); form.disable();
@@ -299,19 +327,17 @@ form.markAsDirty();
```typescript ```typescript
// Subscribe to value changes // Subscribe to value changes
form.valueChanges.subscribe((value) => { form.valueChanges.subscribe((value) => {
console.log("Form value:", value); console.log('Form value:', value);
}); });
// Single control with debounce // Single control with debounce
form.controls.email.valueChanges form.controls.email.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((email) => {
.pipe(debounceTime(300), distinctUntilChanged())
.subscribe((email) => {
this.validateEmail(email); this.validateEmail(email);
}); });
// Status changes // Status changes
form.statusChanges.subscribe((status) => { form.statusChanges.subscribe((status) => {
console.log("Form status:", status); // VALID, INVALID, PENDING console.log('Form status:', status); // VALID, INVALID, PENDING
}); });
``` ```
@@ -319,33 +345,33 @@ form.statusChanges.subscribe((status) => {
```typescript ```typescript
import { import {
ValueChangeEvent, ValueChangeEvent,
StatusChangeEvent, StatusChangeEvent,
PristineChangeEvent, PristineChangeEvent,
TouchedChangeEvent, TouchedChangeEvent,
FormSubmittedEvent, FormSubmittedEvent,
FormResetEvent, FormResetEvent,
} from "@angular/forms"; } from '@angular/forms';
form.events.subscribe((event) => { form.events.subscribe((event) => {
if (event instanceof ValueChangeEvent) { if (event instanceof ValueChangeEvent) {
console.log("Value changed:", event.value); console.log('Value changed:', event.value);
} }
if (event instanceof StatusChangeEvent) { if (event instanceof StatusChangeEvent) {
console.log("Status changed:", event.status); console.log('Status changed:', event.status);
} }
if (event instanceof PristineChangeEvent) { if (event instanceof PristineChangeEvent) {
console.log("Pristine changed:", event.pristine); console.log('Pristine changed:', event.pristine);
} }
if (event instanceof TouchedChangeEvent) { if (event instanceof TouchedChangeEvent) {
console.log("Touched changed:", event.touched); console.log('Touched changed:', event.touched);
} }
if (event instanceof FormSubmittedEvent) { if (event instanceof FormSubmittedEvent) {
console.log("Form submitted"); console.log('Form submitted');
} }
if (event instanceof FormResetEvent) { if (event instanceof FormResetEvent) {
console.log("Form reset"); console.log('Form reset');
} }
}); });
``` ```
@@ -353,27 +379,27 @@ form.events.subscribe((event) => {
```typescript ```typescript
@Component({ @Component({
template: ` template: `
<input formControlName="email" /> <input formControlName="email" />
@if (form.controls.email.invalid && form.controls.email.touched) { @if (form.controls.email.invalid && form.controls.email.touched) {
<div class="errors"> <div class="errors">
@if (form.controls.email.errors?.["required"]) { @if (form.controls.email.errors?.['required']) {
<span>Email is required</span> <span>Email is required</span>
}
@if (form.controls.email.errors?.['email']) {
<span>Invalid email format</span>
}
</div>
} }
@if (form.controls.email.errors?.["email"]) { `,
<span>Invalid email format</span>
}
</div>
}
`,
}) })
export class Form { export class Form {
// Helper for cleaner templates // Helper for cleaner templates
hasError(controlName: string, errorKey: string): boolean { hasError(controlName: string, errorKey: string): boolean {
const control = this.form.get(controlName); const control = this.form.get(controlName);
return (control?.hasError(errorKey) && control?.touched) || false; return (control?.hasError(errorKey) && control?.touched) || false;
} }
} }
``` ```
@@ -381,33 +407,37 @@ export class Form {
```typescript ```typescript
@Component({ @Component({
template: ` template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <form
<!-- fields --> [formGroup]="form"
<button type="submit" [disabled]="form.invalid || isSubmitting"> (ngSubmit)="onSubmit()">
{{ isSubmitting ? "Submitting..." : "Submit" }} <!-- fields -->
</button> <button
</form> type="submit"
`, [disabled]="form.invalid || isSubmitting">
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</button>
</form>
`,
}) })
export class Form { export class Form {
isSubmitting = false; isSubmitting = false;
async onSubmit() { async onSubmit() {
if (this.form.invalid) { if (this.form.invalid) {
this.form.markAllAsTouched(); this.form.markAllAsTouched();
return; return;
} }
this.isSubmitting = true; this.isSubmitting = true;
try { try {
await this.api.submit(this.form.getRawValue()); await this.api.submit(this.form.getRawValue());
this.form.reset(); this.form.reset();
} catch (error) { } catch (error) {
// Handle error // Handle error
} finally { } finally {
this.isSubmitting = false; this.isSubmitting = false;
}
} }
}
} }
``` ```
@@ -8,100 +8,94 @@
```typescript ```typescript
interface Rating { interface Rating {
rating: number; rating: number;
} }
import { import { form, FormField, FormValueControl, ValidationError, WithOptionalField } from '@angular/forms/signals';
form, import { MatIconModule } from '@angular/material/icon';
FormField, import { MatError } from '@angular/material/form-field';
FormValueControl,
ValidationError,
WithOptionalField,
} from "@angular/forms/signals";
import { MatIconModule } from "@angular/material/icon";
import { MatError } from "@angular/material/form-field";
@Component({ @Component({
selector: "app-rating", selector: 'app-rating',
imports: [MatIconModule, MatError], imports: [MatIconModule, MatError],
template: ` template: `
<div class="star-rating-container"> <div class="star-rating-container">
@for (star of starArray(); track $index) { @for (star of starArray(); track $index) {
<mat-icon <mat-icon
(click)="rate(star)" class="star-icon"
class="star-icon" [class.readonly]="readonly()"
[class.readonly]="readonly()" [class.error]="invalid()"
[class.error]="invalid()" [class]="{ filled: star <= value() }"
[class]="{ filled: star <= value() }" (click)="rate(star)">
> {{ getStarIcon(star) }}
{{ getStarIcon(star) }} </mat-icon>
</mat-icon> }
} @if (errors().at(0)?.message) {
@if (errors().at(0)?.message) { <mat-error>
<mat-error> {{ errors().at(0)?.message }}
{{ errors().at(0)?.message }} </mat-error>
</mat-error> }
} </div>
</div> `,
`, styles: ``,
styles: ``,
}) })
export class Rating implements FormValueControl<number> { export class Rating implements FormValueControl<number> {
// Required: The value of the control, exposed as a two-way binding. // Required: The value of the control, exposed as a two-way binding.
readonly value = model<number>(0); readonly value = model<number>(0);
// Optional: Bindings for other form control states. // Optional: Bindings for other form control states.
readonly readonly = input<boolean>(false); readonly readonly = input<boolean>(false);
readonly invalid = input<boolean>(false); readonly invalid = input<boolean>(false);
readonly errors: InputSignal<readonly WithOptionalField<ValidationError>[]> = readonly errors: InputSignal<readonly WithOptionalField<ValidationError>[]> = input<readonly WithOptionalField<ValidationError>[]>([]);
input<readonly WithOptionalField<ValidationError>[]>([]);
starArray: Signal<number[]> = signal( starArray: Signal<number[]> = signal(
Array(5) Array(5)
.fill(0) .fill(0)
.map((_, i) => i + 1), .map((_, i) => i + 1),
); );
getStarIcon(index: number): string { getStarIcon(index: number): string {
const floorRating = Math.floor(this.value()); const floorRating = Math.floor(this.value());
if (index <= floorRating) { if (index <= floorRating) {
return "star"; // Full star return 'star'; // Full star
} else { } else {
return "star_border"; // Empty star return 'star_border'; // Empty star
}
} }
} rate(index: number): void {
rate(index: number): void { if (!this.readonly()) {
if (!this.readonly()) { this.value.set(index);
this.value.set(index); }
} }
}
} }
import { FormField } from "@angular/forms/signals"; import { FormField } from '@angular/forms/signals';
@Component({ @Component({
selector: "app-signal-forms", selector: 'app-signal-forms',
imports: [FormField, Rating], imports: [FormField, Rating],
template: ` template: `
<form autocomplete="off" (submit)="submit($event)"> <form
<div class="form-field"> autocomplete="off"
<app-rating [formField]="ratingForm.rating"> </app-rating> (submit)="submit($event)">
<!-- print to show the value updation --> <div class="form-field">
{{ ratingForm.rating().value() }} <app-rating [formField]="ratingForm.rating"> </app-rating>
</div> <!-- print to show the value updation -->
</form> {{ ratingForm.rating().value() }}
`, </div>
styles: ``, </form>
`,
styles: ``,
}) })
export class SignalForms { export class SignalForms {
readonly ratingModel = signal<Rating>({ readonly ratingModel = signal<Rating>({
rating: 0, rating: 0,
}); });
readonly ratingForm = form(this.ratingModel); readonly ratingForm = form(this.ratingModel);
submit(event: Event): void { submit(event: Event): void {
event.preventDefault(); event.preventDefault();
console.log(this.ratingForm.rating().value()); console.log(this.ratingForm.rating().value());
} }
} }
``` ```
+119 -129
View File
@@ -12,34 +12,34 @@ Fetch data in Angular using signal-based `resource()`, `httpResource()`, and the
`httpResource()` wraps HttpClient with signal-based state management: `httpResource()` wraps HttpClient with signal-based state management:
```typescript ```typescript
import { Component, signal } from "@angular/core"; import { Component, signal } from '@angular/core';
import { httpResource } from "@angular/common/http"; import { httpResource } from '@angular/common/http';
interface User { interface User {
id: number; id: number;
name: string; name: string;
email: string; email: string;
} }
@Component({ @Component({
selector: "app-user-profile", selector: 'app-user-profile',
template: ` template: `
@if (userResource.isLoading()) { @if (userResource.isLoading()) {
<p>Loading...</p> <p>Loading...</p>
} @else if (userResource.error()) { } @else if (userResource.error()) {
<p>Error: {{ userResource.error()?.message }}</p> <p>Error: {{ userResource.error()?.message }}</p>
<button (click)="userResource.reload()">Retry</button> <button (click)="userResource.reload()">Retry</button>
} @else if (userResource.hasValue()) { } @else if (userResource.hasValue()) {
<h1>{{ userResource.value().name }}</h1> <h1>{{ userResource.value().name }}</h1>
<p>{{ userResource.value().email }}</p> <p>{{ userResource.value().email }}</p>
} }
`, `,
}) })
export class UserProfile { export class UserProfile {
userId = signal("123"); userId = signal('123');
// Reactive HTTP resource - refetches when userId changes // Reactive HTTP resource - refetches when userId changes
userResource = httpResource<User>(() => `/api/users/${this.userId()}`); userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
} }
``` ```
@@ -51,21 +51,21 @@ userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
// With full request options // With full request options
userResource = httpResource<User>(() => ({ userResource = httpResource<User>(() => ({
url: `/api/users/${this.userId()}`, url: `/api/users/${this.userId()}`,
method: "GET", method: 'GET',
headers: { Authorization: `Bearer ${this.token()}` }, headers: { Authorization: `Bearer ${this.token()}` },
params: { include: "profile" }, params: { include: 'profile' },
})); }));
// With default value // With default value
usersResource = httpResource<User[]>(() => "/api/users", { usersResource = httpResource<User[]>(() => '/api/users', {
defaultValue: [], defaultValue: [],
}); });
// Skip request when params undefined // Skip request when params undefined
userResource = httpResource<User>(() => { userResource = httpResource<User>(() => {
const id = this.userId(); const id = this.userId();
return id ? `/api/users/${id}` : undefined; return id ? `/api/users/${id}` : undefined;
}); });
``` ```
@@ -117,12 +117,12 @@ export class Search {
```typescript ```typescript
todosResource = resource({ todosResource = resource({
defaultValue: [] as Todo[], defaultValue: [] as Todo[],
params: () => ({ filter: this.filter() }), params: () => ({ filter: this.filter() }),
loader: async ({ params }) => { loader: async ({ params }) => {
const res = await fetch(`/api/todos?filter=${params.filter}`); const res = await fetch(`/api/todos?filter=${params.filter}`);
return res.json(); return res.json();
}, },
}); });
// value() returns Todo[] (never undefined) // value() returns Todo[] (never undefined)
@@ -134,14 +134,14 @@ todosResource = resource({
const userId = signal<string | null>(null); const userId = signal<string | null>(null);
userResource = resource({ userResource = resource({
params: () => { params: () => {
const id = userId(); const id = userId();
// Return undefined to skip loading // Return undefined to skip loading
return id ? { id } : undefined; return id ? { id } : undefined;
}, },
loader: async ({ params }) => { loader: async ({ params }) => {
return fetch(`/api/users/${params.id}`).then((r) => r.json()); return fetch(`/api/users/${params.id}`).then((r) => r.json());
}, },
}); });
// Status is 'idle' when params returns undefined // Status is 'idle' when params returns undefined
``` ```
@@ -204,18 +204,18 @@ deleteUser(id: string) {
### Request Options ### Request Options
```typescript ```typescript
this.http.get<User[]>("/api/users", { this.http.get<User[]>('/api/users', {
headers: { headers: {
Authorization: "Bearer token", 'Authorization': 'Bearer token',
"Content-Type": "application/json", 'Content-Type': 'application/json',
}, },
params: { params: {
page: "1", page: '1',
limit: "10", limit: '10',
sort: "name", sort: 'name',
}, },
observe: "response", // Get full HttpResponse observe: 'response', // Get full HttpResponse
responseType: "json", responseType: 'json',
}); });
``` ```
@@ -225,44 +225,43 @@ this.http.get<User[]>("/api/users", {
```typescript ```typescript
// auth.interceptor.ts // auth.interceptor.ts
import { HttpInterceptorFn } from "@angular/common/http"; import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from "@angular/core"; import { inject } from '@angular/core';
export const authInterceptor: HttpInterceptorFn = (req, next) => { export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(Auth); const authService = inject(Auth);
const token = authService.token(); const token = authService.token();
if (token) { if (token) {
req = req.clone({ req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }, setHeaders: { Authorization: `Bearer ${token}` },
}); });
} }
return next(req); return next(req);
}; };
// error.interceptor.ts // error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => { export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe( return next(req).pipe(
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (error.status === 401) { if (error.status === 401) {
inject(Router).navigate(["/login"]); inject(Router).navigate(['/login']);
} }
return throwError(() => error); return throwError(() => error);
}), }),
); );
}; };
// logging.interceptor.ts // logging.interceptor.ts
export const loggingInterceptor: HttpInterceptorFn = (req, next) => { export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const started = Date.now(); const started = Date.now();
return next(req).pipe( return next(req).pipe(
tap({ tap({
next: () => next: () => console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`),
console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`), error: (err) => console.error(`${req.method} ${req.url} failed`, err),
error: (err) => console.error(`${req.method} ${req.url} failed`, err), }),
}), );
);
}; };
``` ```
@@ -270,14 +269,10 @@ export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
```typescript ```typescript
// app.config.ts // app.config.ts
import { provideHttpClient, withInterceptors } from "@angular/common/http"; import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [provideHttpClient(withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor]))],
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor]),
),
],
}; };
``` ```
@@ -287,26 +282,24 @@ export const appConfig: ApplicationConfig = {
```typescript ```typescript
@Component({ @Component({
template: ` template: `
@if (userResource.error(); as error) { @if (userResource.error(); as error) {
<div class="error"> <div class="error">
<p>{{ getErrorMessage(error) }}</p> <p>{{ getErrorMessage(error) }}</p>
<button (click)="userResource.reload()">Retry</button> <button (click)="userResource.reload()">Retry</button>
</div> </div>
} }
`, `,
}) })
export class UserCmpt { export class UserCmpt {
userResource = httpResource<User>(() => `/api/users/${this.userId()}`); userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
getErrorMessage(error: unknown): string { getErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) { if (error instanceof HttpErrorResponse) {
return ( return error.error?.message || `Error ${error.status}: ${error.statusText}`;
error.error?.message || `Error ${error.status}: ${error.statusText}` }
); return 'An unexpected error occurred';
} }
return "An unexpected error occurred";
}
} }
``` ```
@@ -330,35 +323,32 @@ getUser(id: string) {
```typescript ```typescript
@Component({ @Component({
template: ` template: `
@switch (dataResource.status()) { @switch (dataResource.status()) {
@case ("idle") { @case ('idle') {
<p>Enter a search term</p> <p>Enter a search term</p>
} }
@case ("loading") { @case ('loading') {
<app-spinner /> <app-spinner />
} }
@case ("reloading") { @case ('reloading') {
<app-data [data]="dataResource.value()" /> <app-data [data]="dataResource.value()" />
<app-spinner size="small" /> <app-spinner size="small" />
} }
@case ("resolved") { @case ('resolved') {
<app-data [data]="dataResource.value()" /> <app-data [data]="dataResource.value()" />
} }
@case ("error") { @case ('error') {
<app-error <app-error
[error]="dataResource.error()" [error]="dataResource.error()"
(retry)="dataResource.reload()" (retry)="dataResource.reload()" />
/> }
} }
} `,
`,
}) })
export class Data { export class Data {
query = signal(""); query = signal('');
dataResource = httpResource<Data[]>(() => dataResource = httpResource<Data[]>(() => (this.query() ? `/api/search?q=${this.query()}` : undefined));
this.query() ? `/api/search?q=${this.query()}` : undefined,
);
} }
``` ```
@@ -14,55 +14,55 @@
Encapsulate HTTP logic in services: Encapsulate HTTP logic in services:
```typescript ```typescript
import { Injectable, inject, signal, computed } from "@angular/core"; import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from "@angular/common/http"; import { HttpClient } from '@angular/common/http';
import { httpResource } from "@angular/common/http"; import { httpResource } from '@angular/common/http';
export interface User { export interface User {
id: string; id: string;
name: string; name: string;
email: string; email: string;
} }
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class User { export class User {
private http = inject(HttpClient); private http = inject(HttpClient);
private baseUrl = "/api/users"; private baseUrl = '/api/users';
// Current user ID for reactive fetching // Current user ID for reactive fetching
private currentUserId = signal<string | null>(null); private currentUserId = signal<string | null>(null);
// Reactive resource that updates when currentUserId changes // Reactive resource that updates when currentUserId changes
currentUser = httpResource<User>(() => { currentUser = httpResource<User>(() => {
const id = this.currentUserId(); const id = this.currentUserId();
return id ? `${this.baseUrl}/${id}` : undefined; return id ? `${this.baseUrl}/${id}` : undefined;
}); });
// Set current user to fetch // Set current user to fetch
selectUser(id: string) { selectUser(id: string) {
this.currentUserId.set(id); this.currentUserId.set(id);
} }
// CRUD operations // CRUD operations
getAll() { getAll() {
return this.http.get<User[]>(this.baseUrl); return this.http.get<User[]>(this.baseUrl);
} }
getById(id: string) { getById(id: string) {
return this.http.get<User>(`${this.baseUrl}/${id}`); return this.http.get<User>(`${this.baseUrl}/${id}`);
} }
create(user: Omit<User, "id">) { create(user: Omit<User, 'id'>) {
return this.http.post<User>(this.baseUrl, user); return this.http.post<User>(this.baseUrl, user);
} }
update(id: string, user: Partial<User>) { update(id: string, user: Partial<User>) {
return this.http.patch<User>(`${this.baseUrl}/${id}`, user); return this.http.patch<User>(`${this.baseUrl}/${id}`, user);
} }
delete(id: string) { delete(id: string) {
return this.http.delete<void>(`${this.baseUrl}/${id}`); return this.http.delete<void>(`${this.baseUrl}/${id}`);
} }
} }
``` ```
@@ -71,67 +71,67 @@ export class User {
### Simple In-Memory Cache ### Simple In-Memory Cache
```typescript ```typescript
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class CachedUser { export class CachedUser {
private http = inject(HttpClient); private http = inject(HttpClient);
private cache = new Map<string, { data: User; timestamp: number }>(); private cache = new Map<string, { data: User; timestamp: number }>();
private cacheDuration = 5 * 60 * 1000; // 5 minutes private cacheDuration = 5 * 60 * 1000; // 5 minutes
getUser(id: string): Observable<User> { getUser(id: string): Observable<User> {
const cached = this.cache.get(id); const cached = this.cache.get(id);
if (cached && Date.now() - cached.timestamp < this.cacheDuration) { if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
return of(cached.data); return of(cached.data);
}
return this.http.get<User>(`/api/users/${id}`).pipe(
tap((user) => {
this.cache.set(id, { data: user, timestamp: Date.now() });
}),
);
} }
return this.http.get<User>(`/api/users/${id}`).pipe( invalidateCache(id?: string) {
tap((user) => { if (id) {
this.cache.set(id, { data: user, timestamp: Date.now() }); this.cache.delete(id);
}), } else {
); this.cache.clear();
} }
invalidateCache(id?: string) {
if (id) {
this.cache.delete(id);
} else {
this.cache.clear();
} }
}
} }
``` ```
### Signal-Based Cache ### Signal-Based Cache
```typescript ```typescript
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class UserCache { export class UserCache {
private http = inject(HttpClient); private http = inject(HttpClient);
// Cache as signal // Cache as signal
private usersCache = signal<Map<string, User>>(new Map()); private usersCache = signal<Map<string, User>>(new Map());
// Computed for easy access // Computed for easy access
users = computed(() => Array.from(this.usersCache().values())); users = computed(() => Array.from(this.usersCache().values()));
getUser(id: string): User | undefined { getUser(id: string): User | undefined {
return this.usersCache().get(id); return this.usersCache().get(id);
} }
async fetchUser(id: string): Promise<User> { async fetchUser(id: string): Promise<User> {
const cached = this.getUser(id); const cached = this.getUser(id);
if (cached) return cached; if (cached) return cached;
const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`)); const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
this.usersCache.update((cache) => { this.usersCache.update((cache) => {
const newCache = new Map(cache); const newCache = new Map(cache);
newCache.set(id, user); newCache.set(id, user);
return newCache; return newCache;
}); });
return user; return user;
} }
} }
``` ```
@@ -141,58 +141,61 @@ export class UserCache {
```typescript ```typescript
interface PaginatedResponse<T> { interface PaginatedResponse<T> {
data: T[]; data: T[];
total: number; total: number;
page: number; page: number;
pageSize: number; pageSize: number;
totalPages: number; totalPages: number;
} }
@Component({ @Component({
template: ` template: `
@if (usersResource.isLoading()) { @if (usersResource.isLoading()) {
<app-spinner /> <app-spinner />
} @else if (usersResource.hasValue()) { } @else if (usersResource.hasValue()) {
<ul> <ul>
@for (user of usersResource.value().data; track user.id) { @for (user of usersResource.value().data; track user.id) {
<li>{{ user.name }}</li> <li>{{ user.name }}</li>
}
</ul>
<div class="pagination">
<button
[disabled]="page() === 1"
(click)="prevPage()">
Previous
</button>
<span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>
<button
[disabled]="page() >= usersResource.value().totalPages"
(click)="nextPage()">
Next
</button>
</div>
} }
</ul> `,
<div class="pagination">
<button (click)="prevPage()" [disabled]="page() === 1">Previous</button>
<span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>
<button
(click)="nextPage()"
[disabled]="page() >= usersResource.value().totalPages"
>
Next
</button>
</div>
}
`,
}) })
export class UsersList { export class UsersList {
page = signal(1); page = signal(1);
pageSize = signal(10); pageSize = signal(10);
usersResource = httpResource<PaginatedResponse<User>>(() => ({ usersResource = httpResource<PaginatedResponse<User>>(() => ({
url: "/api/users", url: '/api/users',
params: { params: {
page: this.page().toString(), page: this.page().toString(),
pageSize: this.pageSize().toString(), pageSize: this.pageSize().toString(),
}, },
})); }));
nextPage() { nextPage() {
this.page.update((p) => p + 1); this.page.update((p) => p + 1);
} }
prevPage() { prevPage() {
this.page.update((p) => Math.max(1, p - 1)); this.page.update((p) => Math.max(1, p - 1));
} }
} }
``` ```
@@ -200,58 +203,58 @@ export class UsersList {
```typescript ```typescript
@Component({ @Component({
template: ` template: `
<ul> <ul>
@for (user of allUsers(); track user.id) { @for (user of allUsers(); track user.id) {
<li>{{ user.name }}</li> <li>{{ user.name }}</li>
} }
</ul> </ul>
@if (isLoading()) { @if (isLoading()) {
<app-spinner /> <app-spinner />
} }
@if (hasMore()) { @if (hasMore()) {
<button (click)="loadMore()">Load More</button> <button (click)="loadMore()">Load More</button>
} }
`, `,
}) })
export class InfiniteUsers { export class InfiniteUsers {
private http = inject(HttpClient); private http = inject(HttpClient);
private page = signal(1); private page = signal(1);
private users = signal<User[]>([]); private users = signal<User[]>([]);
private totalPages = signal(1); private totalPages = signal(1);
allUsers = this.users.asReadonly(); allUsers = this.users.asReadonly();
isLoading = signal(false); isLoading = signal(false);
hasMore = computed(() => this.page() < this.totalPages()); hasMore = computed(() => this.page() < this.totalPages());
constructor() { constructor() {
this.loadPage(1); this.loadPage(1);
} }
loadMore() { loadMore() {
this.loadPage(this.page() + 1); this.loadPage(this.page() + 1);
} }
private async loadPage(page: number) { private async loadPage(page: number) {
this.isLoading.set(true); this.isLoading.set(true);
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.get<PaginatedResponse<User>>("/api/users", { this.http.get<PaginatedResponse<User>>('/api/users', {
params: { page: page.toString(), pageSize: "20" }, params: { page: page.toString(), pageSize: '20' },
}), }),
); );
this.users.update((users) => [...users, ...response.data]); this.users.update((users) => [...users, ...response.data]);
this.page.set(page); this.page.set(page);
this.totalPages.set(response.totalPages); this.totalPages.set(response.totalPages);
} finally { } finally {
this.isLoading.set(false); this.isLoading.set(false);
}
} }
}
} }
``` ```
@@ -261,42 +264,44 @@ export class InfiniteUsers {
```typescript ```typescript
@Component({ @Component({
template: ` template: `
<input type="file" (change)="onFileSelected($event)" /> <input
type="file"
(change)="onFileSelected($event)" />
@if (uploadProgress() !== null) { @if (uploadProgress() !== null) {
<progress [value]="uploadProgress()" max="100"></progress> <progress
} max="100"
`, [value]="uploadProgress()"></progress>
}
`,
}) })
export class FileUpload { export class FileUpload {
private http = inject(HttpClient); private http = inject(HttpClient);
uploadProgress = signal<number | null>(null); uploadProgress = signal<number | null>(null);
onFileSelected(event: Event) { onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]; const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return; if (!file) return;
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append('file', file);
this.http this.http
.post("/api/upload", formData, { .post('/api/upload', formData, {
reportProgress: true, reportProgress: true,
observe: "events", observe: 'events',
}) })
.subscribe((event) => { .subscribe((event) => {
if (event.type === HttpEventType.UploadProgress && event.total) { if (event.type === HttpEventType.UploadProgress && event.total) {
this.uploadProgress.set( this.uploadProgress.set(Math.round((100 * event.loaded) / event.total));
Math.round((100 * event.loaded) / event.total), } else if (event.type === HttpEventType.Response) {
); this.uploadProgress.set(null);
} else if (event.type === HttpEventType.Response) { console.log('Upload complete:', event.body);
this.uploadProgress.set(null); }
console.log("Upload complete:", event.body); });
} }
});
}
} }
``` ```
@@ -321,13 +326,13 @@ uploadFiles(files: FileList) {
```typescript ```typescript
// resource() automatically handles cancellation via abortSignal // resource() automatically handles cancellation via abortSignal
searchResource = resource({ searchResource = resource({
params: () => ({ q: this.query() }), params: () => ({ q: this.query() }),
loader: async ({ params, abortSignal }) => { loader: async ({ params, abortSignal }) => {
const response = await fetch(`/api/search?q=${params.q}`, { const response = await fetch(`/api/search?q=${params.q}`, {
signal: abortSignal, // Cancels if params change signal: abortSignal, // Cancels if params change
}); });
return response.json(); return response.json();
}, },
}); });
``` ```
@@ -383,64 +388,64 @@ export class SearchDebounced {
### Testing httpResource ### Testing httpResource
```typescript ```typescript
describe("UserCmpt", () => { describe('UserCmpt', () => {
let component: UserCmpt; let component: UserCmpt;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [UserCmpt], imports: [UserCmpt],
providers: [provideHttpClientTesting()], providers: [provideHttpClientTesting()],
});
component = TestBed.createComponent(UserCmpt).componentInstance;
httpMock = TestBed.inject(HttpTestingController);
}); });
component = TestBed.createComponent(UserCmpt).componentInstance; it('should load user', () => {
httpMock = TestBed.inject(HttpTestingController); component.userId.set('123');
});
it("should load user", () => { const req = httpMock.expectOne('/api/users/123');
component.userId.set("123"); req.flush({ id: '123', name: 'Test User' });
const req = httpMock.expectOne("/api/users/123"); expect(component.userResource.value()?.name).toBe('Test User');
req.flush({ id: "123", name: "Test User" }); });
expect(component.userResource.value()?.name).toBe("Test User"); afterEach(() => {
}); httpMock.verify();
});
afterEach(() => {
httpMock.verify();
});
}); });
``` ```
### Testing Services ### Testing Services
```typescript ```typescript
describe("User", () => { describe('User', () => {
let service: User; let service: User;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [User, provideHttpClient(), provideHttpClientTesting()], providers: [User, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(User);
httpMock = TestBed.inject(HttpTestingController);
}); });
service = TestBed.inject(User); it('should create user', () => {
httpMock = TestBed.inject(HttpTestingController); const newUser = { name: 'Test', email: 'test@example.com' };
});
it("should create user", () => { service.create(newUser).subscribe((user) => {
const newUser = { name: "Test", email: "test@example.com" }; expect(user.id).toBeDefined();
expect(user.name).toBe('Test');
});
service.create(newUser).subscribe((user) => { const req = httpMock.expectOne('/api/users');
expect(user.id).toBeDefined(); expect(req.request.method).toBe('POST');
expect(user.name).toBe("Test"); expect(req.request.body).toEqual(newUser);
req.flush({ id: '1', ...newUser });
}); });
const req = httpMock.expectOne("/api/users");
expect(req.request.method).toBe("POST");
expect(req.request.body).toEqual(newUser);
req.flush({ id: "1", ...newUser });
});
}); });
``` ```
+60 -54
View File
@@ -11,38 +11,46 @@ Configure routing in Angular v20+ with lazy loading, functional guards, and sign
```typescript ```typescript
// app.routes.ts // app.routes.ts
import { Routes } from "@angular/router"; import { Routes } from '@angular/router';
export const routes: Routes = [ export const routes: Routes = [
{ path: "", redirectTo: "/home", pathMatch: "full" }, { path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: "home", component: Home }, { path: 'home', component: Home },
{ path: "about", component: About }, { path: 'about', component: About },
{ path: "**", component: NotFound }, { path: '**', component: NotFound },
]; ];
// app.config.ts // app.config.ts
import { ApplicationConfig } from "@angular/core"; import { ApplicationConfig } 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: [provideRouter(routes)], providers: [provideRouter(routes)],
}; };
// app.component.ts // app.component.ts
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from "@angular/router"; import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({ @Component({
selector: "app-root", selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive], imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: ` template: `
<nav> <nav>
<a routerLink="/home" routerLinkActive="active">Home</a> <a
<a routerLink="/about" routerLinkActive="active">About</a> routerLink="/home"
</nav> routerLinkActive="active"
<router-outlet /> >Home</a
`, >
<a
routerLink="/about"
routerLinkActive="active"
>About</a
>
</nav>
<router-outlet />
`,
}) })
export class App {} export class App {}
``` ```
@@ -54,29 +62,27 @@ Load feature modules on demand:
```typescript ```typescript
// app.routes.ts // app.routes.ts
export const routes: Routes = [ export const routes: Routes = [
{ path: "", redirectTo: "/home", pathMatch: "full" }, { path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: "home", component: Home }, { path: 'home', component: Home },
// Lazy load entire feature // Lazy load entire feature
{ {
path: "admin", path: 'admin',
loadChildren: () => loadChildren: () => import('./admin/admin.routes').then((m) => m.adminRoutes),
import("./admin/admin.routes").then((m) => m.adminRoutes), },
},
// Lazy load single component // Lazy load single component
{ {
path: "settings", path: 'settings',
loadComponent: () => loadComponent: () => import('./settings/settings.component').then((m) => m.Settings),
import("./settings/settings.component").then((m) => m.Settings), },
},
]; ];
// admin/admin.routes.ts // admin/admin.routes.ts
export const adminRoutes: Routes = [ export const adminRoutes: Routes = [
{ path: "", component: AdminDashboard }, { path: '', component: AdminDashboard },
{ path: "users", component: AdminUsers }, { path: 'users', component: AdminUsers },
{ path: "settings", component: AdminSettings }, { path: 'settings', component: AdminSettings },
]; ];
``` ```
@@ -110,10 +116,10 @@ Enable with `withComponentInputBinding()`:
```typescript ```typescript
// app.config.ts // app.config.ts
import { provideRouter, withComponentInputBinding } from "@angular/router"; import { provideRouter, withComponentInputBinding } from '@angular/router';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withComponentInputBinding())], providers: [provideRouter(routes, withComponentInputBinding())],
}; };
``` ```
@@ -282,25 +288,25 @@ export class UserDetail {
```typescript ```typescript
// Parent route with children // Parent route with children
export const routes: Routes = [ export const routes: Routes = [
{ {
path: "products", path: 'products',
component: ProductsLayout, component: ProductsLayout,
children: [ children: [
{ path: "", component: ProductList }, { path: '', component: ProductList },
{ path: ":id", component: ProductDetail }, { path: ':id', component: ProductDetail },
{ path: ":id/edit", component: ProductEdit }, { path: ':id/edit', component: ProductEdit },
], ],
}, },
]; ];
// ProductsLayout // ProductsLayout
@Component({ @Component({
imports: [RouterOutlet], imports: [RouterOutlet],
template: ` template: `
<h1>Products</h1> <h1>Products</h1>
<router-outlet /> <router-outlet />
<!-- Child routes render here --> <!-- Child routes render here -->
`, `,
}) })
export class ProductsLayout {} export class ProductsLayout {}
``` ```
@@ -52,9 +52,9 @@
```typescript ```typescript
export const userTitleResolver: ResolveFn<string> = (route) => { export const userTitleResolver: ResolveFn<string> = (route) => {
const userService = inject(User); const userService = inject(User);
const id = route.paramMap.get("id")!; const id = route.paramMap.get('id')!;
return userService.getById(id).pipe(map((user) => `${user.name} - Profile`)); return userService.getById(id).pipe(map((user) => `${user.name} - Profile`));
}; };
``` ```
@@ -64,107 +64,110 @@ export const userTitleResolver: ResolveFn<string> = (route) => {
```typescript ```typescript
// auth.service.ts // auth.service.ts
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class Auth { export class Auth {
private _user = signal<User | null>(null); private _user = signal<User | null>(null);
private _token = signal<string | null>(null); private _token = signal<string | null>(null);
readonly user = this._user.asReadonly(); readonly user = this._user.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null); readonly isAuthenticated = computed(() => this._user() !== null);
private router = inject(Router); private router = inject(Router);
private http = inject(HttpClient); private http = inject(HttpClient);
async login(credentials: Credentials): Promise<boolean> { async login(credentials: Credentials): Promise<boolean> {
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(this.http.post<AuthResponse>('/api/login', credentials));
this.http.post<AuthResponse>("/api/login", credentials),
);
this._token.set(response.token); this._token.set(response.token);
this._user.set(response.user); this._user.set(response.user);
localStorage.setItem("token", response.token); localStorage.setItem('token', response.token);
return true; return true;
} catch { } catch {
return false; return false;
}
} }
}
logout(): void { logout(): void {
this._user.set(null); this._user.set(null);
this._token.set(null); this._token.set(null);
localStorage.removeItem("token"); localStorage.removeItem('token');
this.router.navigate(["/login"]); this.router.navigate(['/login']);
} }
async checkAuth(): Promise<boolean> { async checkAuth(): Promise<boolean> {
const token = localStorage.getItem("token"); const token = localStorage.getItem('token');
if (!token) return false; if (!token) return false;
try { try {
const user = await firstValueFrom(this.http.get<User>("/api/me")); const user = await firstValueFrom(this.http.get<User>('/api/me'));
this._user.set(user); this._user.set(user);
this._token.set(token); this._token.set(token);
return true; return true;
} catch { } catch {
localStorage.removeItem("token"); localStorage.removeItem('token');
return false; return false;
}
} }
}
} }
// auth.guard.ts // auth.guard.ts
export const authGuard: CanActivateFn = async (route, state) => { export const authGuard: CanActivateFn = async (route, state) => {
const authService = inject(Auth); const authService = inject(Auth);
const router = inject(Router); const router = inject(Router);
// Check if already authenticated // Check if already authenticated
if (authService.isAuthenticated()) { if (authService.isAuthenticated()) {
return true; return true;
} }
// Try to restore session // Try to restore session
const isValid = await authService.checkAuth(); const isValid = await authService.checkAuth();
if (isValid) { if (isValid) {
return true; return true;
} }
// Redirect to login // Redirect to login
return router.createUrlTree(["/login"], { return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }, queryParams: { returnUrl: state.url },
}); });
}; };
// login.component.ts // login.component.ts
@Component({ @Component({
template: ` template: `
<form (ngSubmit)="login()"> <form (ngSubmit)="login()">
<input [(ngModel)]="email" name="email" /> <input
<input [(ngModel)]="password" name="password" type="password" /> name="email"
<button type="submit">Login</button> [(ngModel)]="email" />
</form> <input
`, name="password"
type="password"
[(ngModel)]="password" />
<button type="submit">Login</button>
</form>
`,
}) })
export class Login { export class Login {
private authService = inject(Auth); private authService = inject(Auth);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
email = ""; email = '';
password = ""; password = '';
async login() { async login() {
const success = await this.authService.login({ const success = await this.authService.login({
email: this.email, email: this.email,
password: this.password, password: this.password,
}); });
if (success) { if (success) {
const returnUrl = this.route.snapshot.queryParams["returnUrl"] || "/"; const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl); this.router.navigateByUrl(returnUrl);
}
} }
}
} }
``` ```
@@ -172,85 +175,79 @@ export class Login {
```typescript ```typescript
// breadcrumb.service.ts // breadcrumb.service.ts
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class Breadcrumb { export class Breadcrumb {
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
breadcrumbs = toSignal( breadcrumbs = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((event) => event instanceof NavigationEnd), filter((event) => event instanceof NavigationEnd),
map(() => this.buildBreadcrumbs(this.route.root)), map(() => this.buildBreadcrumbs(this.route.root)),
), ),
{ initialValue: [] }, { initialValue: [] },
); );
private buildBreadcrumbs( private buildBreadcrumbs(route: ActivatedRoute, url: string = '', breadcrumbs: Breadcrumb[] = []): Breadcrumb[] {
route: ActivatedRoute, const children = route.children;
url: string = "",
breadcrumbs: Breadcrumb[] = [],
): Breadcrumb[] {
const children = route.children;
if (children.length === 0) { if (children.length === 0) {
return breadcrumbs; 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;
} }
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 // Route config with breadcrumb data
export const routes: Routes = [ export const routes: Routes = [
{ {
path: "products", path: 'products',
data: { breadcrumb: "Products" }, data: { breadcrumb: 'Products' },
children: [ children: [
{ path: "", component: ProductList }, { path: '', component: ProductList },
{ {
path: ":id", path: ':id',
data: { breadcrumb: "Product Details" }, data: { breadcrumb: 'Product Details' },
component: ProductDetail, component: ProductDetail,
}, },
], ],
}, },
]; ];
// breadcrumb.component.ts // breadcrumb.component.ts
@Component({ @Component({
selector: "app-breadcrumb", selector: 'app-breadcrumb',
template: ` template: `
<nav aria-label="Breadcrumb"> <nav aria-label="Breadcrumb">
<ol> <ol>
<li><a routerLink="/">Home</a></li> <li><a routerLink="/">Home</a></li>
@for (crumb of breadcrumbService.breadcrumbs(); track crumb.url) { @for (crumb of breadcrumbService.breadcrumbs(); track crumb.url) {
<li> <li>
<a [routerLink]="crumb.url">{{ crumb.label }}</a> <a [routerLink]="crumb.url">{{ crumb.label }}</a>
</li> </li>
} }
</ol> </ol>
</nav> </nav>
`, `,
}) })
export class BreadcrumbCmpt { export class BreadcrumbCmpt {
breadcrumbService = inject(Breadcrumb); breadcrumbService = inject(Breadcrumb);
} }
``` ```
@@ -334,12 +331,7 @@ this.router.navigate([{ outlets: { modal: null } }]);
### Built-in Strategies ### Built-in Strategies
```typescript ```typescript
import { import { provideRouter, withPreloading, PreloadAllModules, NoPreloading } from '@angular/router';
provideRouter,
withPreloading,
PreloadAllModules,
NoPreloading,
} from "@angular/router";
// Preload all lazy modules // Preload all lazy modules
provideRouter(routes, withPreloading(PreloadAllModules)); provideRouter(routes, withPreloading(PreloadAllModules));
@@ -377,26 +369,26 @@ provideRouter(routes, withPreloading(SelectivePreloadStrategy))
### Network-Aware Preloading ### Network-Aware Preloading
```typescript ```typescript
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class NetworkAwarePreloadStrategy implements PreloadingStrategy { export class NetworkAwarePreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> { preload(route: Route, load: () => Observable<any>): Observable<any> {
// Check network conditions // Check network conditions
const connection = (navigator as any).connection; 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();
}
if (connection) {
// Don't preload on slow connections
if (connection.saveData || connection.effectiveType === "2g") {
return of(null); return of(null);
}
} }
// Preload if marked
if (route.data?.["preload"]) {
return load();
}
return of(null);
}
} }
``` ```
@@ -405,44 +397,44 @@ export class NetworkAwarePreloadStrategy implements PreloadingStrategy {
```typescript ```typescript
// app.routes.ts // app.routes.ts
export const routes: Routes = [ export const routes: Routes = [
{ path: "home", component: Home, data: { animation: "HomePage" } }, { path: 'home', component: Home, data: { animation: 'HomePage' } },
{ path: "about", component: About, data: { animation: "AboutPage" } }, { path: 'about', component: About, data: { animation: 'AboutPage' } },
]; ];
// app.component.ts // app.component.ts
@Component({ @Component({
imports: [RouterOutlet], imports: [RouterOutlet],
template: ` template: `
<div [@routeAnimations]="getRouteAnimationData()"> <div [@routeAnimations]="getRouteAnimationData()">
<router-outlet /> <router-outlet />
</div> </div>
`, `,
animations: [ animations: [
trigger("routeAnimations", [ trigger('routeAnimations', [
transition("HomePage <=> AboutPage", [ transition('HomePage <=> AboutPage', [
style({ position: "relative" }), style({ position: 'relative' }),
query(":enter, :leave", [ query(':enter, :leave', [
style({ style({
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: "100%", 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%' }))]),
]),
]),
]), ]),
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 { export class AppMain {
getRouteAnimationData() { getRouteAnimationData() {
return this.route.firstChild?.snapshot.data["animation"]; return this.route.firstChild?.snapshot.data['animation'];
} }
} }
``` ```
@@ -450,20 +442,16 @@ export class AppMain {
```typescript ```typescript
// app.config.ts // app.config.ts
import { import { provideRouter, withInMemoryScrolling, withRouterConfig } from '@angular/router';
provideRouter,
withInMemoryScrolling,
withRouterConfig,
} from "@angular/router";
provideRouter( provideRouter(
routes, routes,
withInMemoryScrolling({ withInMemoryScrolling({
scrollPositionRestoration: "enabled", // or 'top' scrollPositionRestoration: 'enabled', // or 'top'
anchorScrolling: "enabled", anchorScrolling: 'enabled',
}), }),
withRouterConfig({ withRouterConfig({
onSameUrlNavigation: "reload", onSameUrlNavigation: 'reload',
}), }),
); );
``` ```
+99 -108
View File
@@ -12,7 +12,7 @@ Signals are Angular's reactive primitive for state management. They provide sync
### signal() - Writable State ### signal() - Writable State
```typescript ```typescript
import { signal } from "@angular/core"; import { signal } from '@angular/core';
// Create writable signal // Create writable signal
const count = signal(0); const count = signal(0);
@@ -28,67 +28,65 @@ count.update((c) => c + 1);
// With explicit type // With explicit type
const user = signal<User | null>(null); const user = signal<User | null>(null);
user.set({ id: 1, name: "Alice" }); user.set({ id: 1, name: 'Alice' });
``` ```
### computed() - Derived State ### computed() - Derived State
```typescript ```typescript
import { signal, computed } from "@angular/core"; import { signal, computed } from '@angular/core';
const firstName = signal("John"); const firstName = signal('John');
const lastName = signal("Doe"); const lastName = signal('Doe');
// Derived signal - automatically updates when dependencies change // Derived signal - automatically updates when dependencies change
const fullName = computed(() => `${firstName()} ${lastName()}`); const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // "John Doe" console.log(fullName()); // "John Doe"
firstName.set("Jane"); firstName.set('Jane');
console.log(fullName()); // "Jane Doe" console.log(fullName()); // "Jane Doe"
// Computed with complex logic // Computed with complex logic
const items = signal<Item[]>([]); const items = signal<Item[]>([]);
const filter = signal(""); const filter = signal('');
const filteredItems = computed(() => { const filteredItems = computed(() => {
const query = filter().toLowerCase(); const query = filter().toLowerCase();
return items().filter((item) => item.name.toLowerCase().includes(query)); return items().filter((item) => item.name.toLowerCase().includes(query));
}); });
const totalPrice = computed(() => const totalPrice = computed(() => filteredItems().reduce((sum, item) => sum + item.price, 0));
filteredItems().reduce((sum, item) => sum + item.price, 0),
);
``` ```
### linkedSignal() - Dependent State with Reset ### linkedSignal() - Dependent State with Reset
```typescript ```typescript
import { signal, linkedSignal } from "@angular/core"; import { signal, linkedSignal } from '@angular/core';
const options = signal(["A", "B", "C"]); const options = signal(['A', 'B', 'C']);
// Resets to first option when options change // Resets to first option when options change
const selected = linkedSignal(() => options()[0]); const selected = linkedSignal(() => options()[0]);
console.log(selected()); // "A" console.log(selected()); // "A"
selected.set("B"); // User selects B selected.set('B'); // User selects B
console.log(selected()); // "B" console.log(selected()); // "B"
options.set(["X", "Y"]); // Options change options.set(['X', 'Y']); // Options change
console.log(selected()); // "X" - auto-reset to first console.log(selected()); // "X" - auto-reset to first
// With previous value access // With previous value access
const items = signal<Item[]>([]); const items = signal<Item[]>([]);
const selectedItem = linkedSignal<Item[], Item | null>({ const selectedItem = linkedSignal<Item[], Item | null>({
source: () => items(), source: () => items(),
computation: (newItems, previous) => { computation: (newItems, previous) => {
// Try to preserve selection if item still exists // Try to preserve selection if item still exists
const prevItem = previous?.value; const prevItem = previous?.value;
if (prevItem && newItems.some((i) => i.id === prevItem.id)) { if (prevItem && newItems.some((i) => i.id === prevItem.id)) {
return prevItem; return prevItem;
} }
return newItems[0] ?? null; return newItems[0] ?? null;
}, },
}); });
``` ```
@@ -128,66 +126,64 @@ export class Search {
```typescript ```typescript
@Component({ @Component({
selector: "app-todo-list", selector: 'app-todo-list',
template: ` template: `
<input <input
[value]="newTodo()" [value]="newTodo()"
(input)="newTodo.set($any($event.target).value)" (input)="newTodo.set($any($event.target).value)" />
/> <button
<button (click)="addTodo()" [disabled]="!canAdd()">Add</button> [disabled]="!canAdd()"
(click)="addTodo()">
Add
</button>
<ul> <ul>
@for (todo of filteredTodos(); track todo.id) { @for (todo of filteredTodos(); track todo.id) {
<li [class.done]="todo.done"> <li [class.done]="todo.done">
{{ todo.text }} {{ todo.text }}
<button (click)="toggleTodo(todo.id)">Toggle</button> <button (click)="toggleTodo(todo.id)">Toggle</button>
</li> </li>
} }
</ul> </ul>
<p>{{ remaining() }} remaining</p> <p>{{ remaining() }} remaining</p>
`, `,
}) })
export class TodoList { export class TodoList {
// State // State
todos = signal<Todo[]>([]); todos = signal<Todo[]>([]);
newTodo = signal(""); newTodo = signal('');
filter = signal<"all" | "active" | "done">("all"); filter = signal<'all' | 'active' | 'done'>('all');
// Derived state // Derived state
canAdd = computed(() => this.newTodo().trim().length > 0); canAdd = computed(() => this.newTodo().trim().length > 0);
filteredTodos = computed(() => { filteredTodos = computed(() => {
const todos = this.todos(); const todos = this.todos();
switch (this.filter()) { switch (this.filter()) {
case "active": case 'active':
return todos.filter((t) => !t.done); return todos.filter((t) => !t.done);
case "done": case 'done':
return todos.filter((t) => t.done); return todos.filter((t) => t.done);
default: default:
return todos; 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('');
}
} }
});
remaining = computed(() => this.todos().filter((t) => !t.done).length); toggleTodo(id: string) {
this.todos.update((todos) => todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
// 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)),
);
}
} }
``` ```
@@ -242,63 +238,58 @@ export class Search {
```typescript ```typescript
// Custom equality function // Custom equality function
const user = signal<User>( const user = signal<User>({ id: 1, name: 'Alice' }, { equal: (a, b) => a.id === b.id });
{ id: 1, name: "Alice" },
{ equal: (a, b) => a.id === b.id },
);
// Only triggers updates when ID changes // Only triggers updates when ID changes
user.set({ id: 1, name: "Alice Updated" }); // No update user.set({ id: 1, name: 'Alice Updated' }); // No update
user.set({ id: 2, name: "Bob" }); // Triggers update user.set({ id: 2, name: 'Bob' }); // Triggers update
``` ```
## Untracked Reads ## Untracked Reads
```typescript ```typescript
import { untracked } from "@angular/core"; import { untracked } from '@angular/core';
const a = signal(1); const a = signal(1);
const b = signal(2); const b = signal(2);
// Only depends on 'a', not 'b' // Only depends on 'a', not 'b'
const result = computed(() => { const result = computed(() => {
const aVal = a(); const aVal = a();
const bVal = untracked(() => b()); const bVal = untracked(() => b());
return aVal + bVal; return aVal + bVal;
}); });
``` ```
## Service State Pattern ## Service State Pattern
```typescript ```typescript
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class Auth { export class Auth {
// Private writable state // Private writable state
private _user = signal<User | null>(null); private _user = signal<User | null>(null);
private _loading = signal(false); private _loading = signal(false);
// Public read-only signals // Public read-only signals
readonly user = this._user.asReadonly(); readonly user = this._user.asReadonly();
readonly loading = this._loading.asReadonly(); readonly loading = this._loading.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null); readonly isAuthenticated = computed(() => this._user() !== null);
private http = inject(HttpClient); private http = inject(HttpClient);
async login(credentials: Credentials): Promise<void> { async login(credentials: Credentials): Promise<void> {
this._loading.set(true); this._loading.set(true);
try { try {
const user = await firstValueFrom( const user = await firstValueFrom(this.http.post<User>('/api/login', credentials));
this.http.post<User>("/api/login", credentials), this._user.set(user);
); } finally {
this._user.set(user); this._loading.set(false);
} finally { }
this._loading.set(false);
} }
}
logout(): void { logout(): void {
this._user.set(null); this._user.set(null);
} }
} }
``` ```
@@ -61,12 +61,12 @@ userResource.update(current => ({ ...current, name: 'Updated' }));
```typescript ```typescript
const todosResource = resource({ const todosResource = resource({
defaultValue: [] as Todo[], defaultValue: [] as Todo[],
params: () => ({ filter: this.filter() }), params: () => ({ filter: this.filter() }),
loader: async ({ params }) => { loader: async ({ params }) => {
const response = await fetch(`/api/todos?filter=${params.filter}`); const response = await fetch(`/api/todos?filter=${params.filter}`);
return response.json(); return response.json();
}, },
}); });
// value() returns Todo[] (never undefined due to defaultValue) // value() returns Todo[] (never undefined due to defaultValue)
@@ -78,14 +78,14 @@ const todosResource = resource({
const userId = signal<string | null>(null); const userId = signal<string | null>(null);
const userResource = resource({ const userResource = resource({
params: () => { params: () => {
const id = userId(); const id = userId();
// Return undefined to skip loading // Return undefined to skip loading
return id ? { id } : undefined; return id ? { id } : undefined;
}, },
loader: async ({ params }) => { loader: async ({ params }) => {
return fetch(`/api/users/${params.id}`).then((r) => r.json()); return fetch(`/api/users/${params.id}`).then((r) => r.json());
}, },
}); });
// Status is 'idle' when params returns undefined // Status is 'idle' when params returns undefined
``` ```
@@ -96,81 +96,75 @@ For complex state, create a dedicated store:
```typescript ```typescript
interface ProductState { interface ProductState {
products: Product[]; products: Product[];
selectedId: string | null; selectedId: string | null;
filter: string; filter: string;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
} }
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class ProductSt { export class ProductSt {
// Private state // Private state
private state = signal<ProductState>({ private state = signal<ProductState>({
products: [], products: [],
selectedId: null, selectedId: null,
filter: "", 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, loading: false,
error: "Failed to load products", error: null,
})); });
}
}
async addProduct(product: Omit<Product, "id">): Promise<void> { // Selectors (computed signals)
const newProduct = await firstValueFrom( readonly products = computed(() => this.state().products);
this.http.post<Product>("/api/products", product), readonly selectedId = computed(() => this.state().selectedId);
); readonly filter = computed(() => this.state().filter);
this.state.update((s) => ({ readonly loading = computed(() => this.state().loading);
...s, readonly error = computed(() => this.state().error);
products: [...s.products, newProduct],
})); 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],
}));
}
} }
``` ```
@@ -291,88 +285,86 @@ export class Search {
### Optimistic Updates ### Optimistic Updates
```typescript ```typescript
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class Todo { export class Todo {
private todos = signal<Todo[]>([]); private todos = signal<Todo[]>([]);
readonly items = this.todos.asReadonly(); readonly items = this.todos.asReadonly();
private http = inject(HttpClient); private http = inject(HttpClient);
async toggleTodo(id: string): Promise<void> { async toggleTodo(id: string): Promise<void> {
// Optimistic update // Optimistic update
const previousTodos = this.todos(); const previousTodos = this.todos();
this.todos.update((todos) => this.todos.update((todos) => todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
try { try {
await firstValueFrom(this.http.patch(`/api/todos/${id}/toggle`, {})); await firstValueFrom(this.http.patch(`/api/todos/${id}/toggle`, {}));
} catch { } catch {
// Rollback on error // Rollback on error
this.todos.set(previousTodos); this.todos.set(previousTodos);
}
} }
}
} }
``` ```
## Testing Signals ## Testing Signals
```typescript ```typescript
describe("Counter", () => { describe('Counter', () => {
it("should increment count", () => { it('should increment count', () => {
const component = new Counter(); const component = new Counter();
expect(component.count()).toBe(0); expect(component.count()).toBe(0);
component.increment(); component.increment();
expect(component.count()).toBe(1); expect(component.count()).toBe(1);
component.increment(); component.increment();
expect(component.count()).toBe(2); expect(component.count()).toBe(2);
}); });
it("should compute doubled value", () => { it('should compute doubled value', () => {
const component = new Counter(); const component = new Counter();
expect(component.doubled()).toBe(0); expect(component.doubled()).toBe(0);
component.count.set(5); component.count.set(5);
expect(component.doubled()).toBe(10); expect(component.doubled()).toBe(10);
}); });
}); });
describe("ProductSt", () => { describe('ProductSt', () => {
let store: ProductSt; let store: ProductSt;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ProductSt, provideHttpClient(), provideHttpClientTesting()], providers: [ProductSt, provideHttpClient(), provideHttpClientTesting()],
});
store = TestBed.inject(ProductSt);
httpMock = TestBed.inject(HttpTestingController);
}); });
store = TestBed.inject(ProductSt); it('should filter products', () => {
httpMock = TestBed.inject(HttpTestingController); // Set initial state
}); store['state'].set({
products: [
{ id: '1', name: 'Apple' },
{ id: '2', name: 'Banana' },
],
selectedId: null,
filter: '',
loading: false,
error: null,
});
it("should filter products", () => { expect(store.filteredProducts().length).toBe(2);
// Set initial state
store["state"].set({ store.setFilter('app');
products: [ expect(store.filteredProducts().length).toBe(1);
{ id: "1", name: "Apple" }, expect(store.filteredProducts()[0].name).toBe('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");
});
}); });
``` ```
@@ -381,19 +373,19 @@ describe("ProductSt", () => {
```typescript ```typescript
// Debug effect to log signal changes // Debug effect to log signal changes
effect(() => { effect(() => {
console.log("State changed:", { console.log('State changed:', {
count: this.count(), count: this.count(),
items: this.items(), items: this.items(),
filter: this.filter(), filter: this.filter(),
}); });
}); });
// Conditional debugging // Conditional debugging
const DEBUG = signal(false); const DEBUG = signal(false);
effect(() => { effect(() => {
if (untracked(() => DEBUG())) { if (untracked(() => DEBUG())) {
console.log("Debug:", this.state()); console.log('Debug:', this.state());
} }
}); });
``` ```
+248 -253
View File
@@ -19,19 +19,19 @@ Configure in angular.json:
```json ```json
{ {
"projects": { "projects": {
"your-app": { "your-app": {
"architect": { "architect": {
"test": { "test": {
"builder": "@angular/build:unit-test", "builder": "@angular/build:unit-test",
"options": { "options": {
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build" "buildTarget": "your-app:build"
} }
}
}
} }
}
} }
}
} }
``` ```
@@ -48,41 +48,41 @@ For Vitest migration from Jasmine and advanced configuration, see [references/vi
## Basic Component Test ## Basic Component Test
```typescript ```typescript
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Counter } from "./counter.component"; import { Counter } from './counter.component';
describe("Counter", () => { describe('Counter', () => {
let component: Counter; let component: Counter;
let fixture: ComponentFixture<Counter>; let fixture: ComponentFixture<Counter>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [Counter], // Standalone component imports: [Counter], // Standalone component
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(Counter); fixture = TestBed.createComponent(Counter);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it("should increment count", () => { it('should increment count', () => {
expect(component.count()).toBe(0); expect(component.count()).toBe(0);
component.increment(); component.increment();
expect(component.count()).toBe(1); expect(component.count()).toBe(1);
}); });
it("should display count in template", () => { it('should display count in template', () => {
component.count.set(5); component.count.set(5);
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement.querySelector(".count"); const element = fixture.nativeElement.querySelector('.count');
expect(element.textContent).toContain("5"); expect(element.textContent).toContain('5');
}); });
}); });
``` ```
@@ -91,21 +91,21 @@ describe("Counter", () => {
### Direct Signal Testing ### Direct Signal Testing
```typescript ```typescript
import { signal, computed } from "@angular/core"; import { signal, computed } from '@angular/core';
describe("Signal logic", () => { describe('Signal logic', () => {
it("should update computed when signal changes", () => { it('should update computed when signal changes', () => {
const count = signal(0); const count = signal(0);
const doubled = computed(() => count() * 2); const doubled = computed(() => count() * 2);
expect(doubled()).toBe(0); expect(doubled()).toBe(0);
count.set(5); count.set(5);
expect(doubled()).toBe(10); expect(doubled()).toBe(10);
count.update((c) => c + 1); count.update((c) => c + 1);
expect(doubled()).toBe(12); expect(doubled()).toBe(12);
}); });
}); });
``` ```
@@ -113,60 +113,60 @@ describe("Signal logic", () => {
```typescript ```typescript
@Component({ @Component({
selector: "app-todo-list", selector: 'app-todo-list',
template: ` template: `
<ul> <ul>
@for (todo of filteredTodos(); track todo.id) { @for (todo of filteredTodos(); track todo.id) {
<li>{{ todo.text }}</li> <li>{{ todo.text }}</li>
} }
</ul> </ul>
<p>{{ remaining() }} remaining</p> <p>{{ remaining() }} remaining</p>
`, `,
}) })
export class TodoList { export class TodoList {
todos = signal<Todo[]>([]); todos = signal<Todo[]>([]);
filter = signal<"all" | "active" | "done">("all"); filter = signal<'all' | 'active' | 'done'>('all');
filteredTodos = computed(() => { filteredTodos = computed(() => {
const todos = this.todos(); const todos = this.todos();
switch (this.filter()) { switch (this.filter()) {
case "active": case 'active':
return todos.filter((t) => !t.done); return todos.filter((t) => !t.done);
case "done": case 'done':
return todos.filter((t) => t.done); return todos.filter((t) => t.done);
default: default:
return todos; return todos;
} }
}); });
remaining = computed(() => this.todos().filter((t) => !t.done).length); remaining = computed(() => this.todos().filter((t) => !t.done).length);
} }
describe("TodoList", () => { describe('TodoList', () => {
let component: TodoList; let component: TodoList;
let fixture: ComponentFixture<TodoList>; let fixture: ComponentFixture<TodoList>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [TodoList], imports: [TodoList],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(TodoList); fixture = TestBed.createComponent(TodoList);
component = fixture.componentInstance; component = fixture.componentInstance;
}); });
it("should filter active todos", () => { it('should filter active todos', () => {
component.todos.set([ component.todos.set([
{ id: "1", text: "Task 1", done: false }, { id: '1', text: 'Task 1', done: false },
{ id: "2", text: "Task 2", done: true }, { id: '2', text: 'Task 2', done: true },
{ id: "3", text: "Task 3", done: false }, { id: '3', text: 'Task 3', done: false },
]); ]);
component.filter.set("active"); component.filter.set('active');
expect(component.filteredTodos().length).toBe(2); expect(component.filteredTodos().length).toBe(2);
expect(component.remaining()).toBe(2); expect(component.remaining()).toBe(2);
}); });
}); });
``` ```
@@ -176,29 +176,29 @@ OnPush components require explicit change detection:
```typescript ```typescript
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span>{{ data().name }}</span>`, template: `<span>{{ data().name }}</span>`,
}) })
export class OnPushCmpt { export class OnPushCmpt {
data = input.required<{ name: string }>(); data = input.required<{ name: string }>();
} }
describe("OnPushCmpt", () => { describe('OnPushCmpt', () => {
it("should update when input signal changes", () => { it('should update when input signal changes', () => {
const fixture = TestBed.createComponent(OnPushCmpt); const fixture = TestBed.createComponent(OnPushCmpt);
// Set input using setInput (for signal inputs) // Set input using setInput (for signal inputs)
fixture.componentRef.setInput("data", { name: "Initial" }); fixture.componentRef.setInput('data', { name: 'Initial' });
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Initial"); expect(fixture.nativeElement.textContent).toContain('Initial');
// Update input // Update input
fixture.componentRef.setInput("data", { name: "Updated" }); fixture.componentRef.setInput('data', { name: 'Updated' });
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Updated"); expect(fixture.nativeElement.textContent).toContain('Updated');
}); });
}); });
``` ```
@@ -207,72 +207,69 @@ describe("OnPushCmpt", () => {
### Basic Service Test ### Basic Service Test
```typescript ```typescript
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class CounterService { export class CounterService {
private _count = signal(0); private _count = signal(0);
readonly count = this._count.asReadonly(); readonly count = this._count.asReadonly();
increment() { increment() {
this._count.update((c) => c + 1); this._count.update((c) => c + 1);
} }
reset() { reset() {
this._count.set(0); this._count.set(0);
} }
} }
describe("CounterService", () => { describe('CounterService', () => {
let service: CounterService; let service: CounterService;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({}); TestBed.configureTestingModule({});
service = TestBed.inject(CounterService); service = TestBed.inject(CounterService);
}); });
it("should increment count", () => { it('should increment count', () => {
expect(service.count()).toBe(0); expect(service.count()).toBe(0);
service.increment(); service.increment();
expect(service.count()).toBe(1); expect(service.count()).toBe(1);
}); });
}); });
``` ```
### Service with HTTP ### Service with HTTP
```typescript ```typescript
import { import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
HttpTestingController, import { provideHttpClient } from '@angular/common/http';
provideHttpClientTesting,
} from "@angular/common/http/testing";
import { provideHttpClient } from "@angular/common/http";
describe("UserService", () => { describe('UserService', () => {
let service: UserService; let service: UserService;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()], providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
}); });
service = TestBed.inject(UserService); afterEach(() => {
httpMock = TestBed.inject(HttpTestingController); httpMock.verify(); // Verify no outstanding requests
});
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"); it('should fetch user by id', () => {
expect(req.request.method).toBe("GET"); const mockUser = { id: '1', name: 'Test User' };
req.flush(mockUser);
}); 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);
});
}); });
``` ```
@@ -281,31 +278,31 @@ describe("UserService", () => {
### Using Vitest Mocks ### Using Vitest Mocks
```typescript ```typescript
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from 'vitest';
describe("UserProfile", () => { describe('UserProfile', () => {
const mockUserService = { const mockUserService = {
getUser: vi.fn(), getUser: vi.fn(),
updateUser: vi.fn(), updateUser: vi.fn(),
user: signal<User | null>(null), user: signal<User | null>(null),
}; };
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockUserService.getUser.mockReturnValue(of({ id: "1", name: "Test" })); mockUserService.getUser.mockReturnValue(of({ id: '1', name: 'Test' }));
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [UserProfile], imports: [UserProfile],
providers: [{ provide: UserService, useValue: mockUserService }], providers: [{ provide: UserService, useValue: mockUserService }],
}).compileComponents(); }).compileComponents();
}); });
it("should call getUser on init", () => { it('should call getUser on init', () => {
const fixture = TestBed.createComponent(UserProfile); const fixture = TestBed.createComponent(UserProfile);
fixture.detectChanges(); fixture.detectChanges();
expect(mockUserService.getUser).toHaveBeenCalledWith("1"); expect(mockUserService.getUser).toHaveBeenCalledWith('1');
}); });
}); });
``` ```
@@ -313,28 +310,26 @@ describe("UserProfile", () => {
```typescript ```typescript
const mockAuth = { const mockAuth = {
user: signal<User | null>(null), user: signal<User | null>(null),
isAuthenticated: computed(() => mockAuth.user() !== null), isAuthenticated: computed(() => mockAuth.user() !== null),
login: vi.fn(), login: vi.fn(),
logout: vi.fn(), logout: vi.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ProtectedPage], imports: [ProtectedPage],
providers: [{ provide: AuthService, useValue: mockAuth }], providers: [{ provide: AuthService, useValue: mockAuth }],
}).compileComponents(); }).compileComponents();
}); });
it("should show content when authenticated", () => { it('should show content when authenticated', () => {
mockAuth.user.set({ id: "1", name: "Test User" }); mockAuth.user.set({ id: '1', name: 'Test User' });
const fixture = TestBed.createComponent(ProtectedPage); const fixture = TestBed.createComponent(ProtectedPage);
fixture.detectChanges(); fixture.detectChanges();
expect( expect(fixture.nativeElement.querySelector('.protected-content')).toBeTruthy();
fixture.nativeElement.querySelector(".protected-content"),
).toBeTruthy();
}); });
``` ```
@@ -342,33 +337,33 @@ it("should show content when authenticated", () => {
```typescript ```typescript
@Component({ @Component({
selector: "app-item", selector: 'app-item',
template: `<div (click)="select()">{{ item().name }}</div>`, template: `<div (click)="select()">{{ item().name }}</div>`,
}) })
export class ItemCmpt { export class ItemCmpt {
item = input.required<Item>(); item = input.required<Item>();
selected = output<Item>(); selected = output<Item>();
select() { select() {
this.selected.emit(this.item()); this.selected.emit(this.item());
} }
} }
describe("ItemCmpt", () => { describe('ItemCmpt', () => {
it("should emit selected event on click", () => { it('should emit selected event on click', () => {
const fixture = TestBed.createComponent(ItemCmpt); const fixture = TestBed.createComponent(ItemCmpt);
const item: Item = { id: "1", name: "Test Item" }; const item: Item = { id: '1', name: 'Test Item' };
fixture.componentRef.setInput("item", item); fixture.componentRef.setInput('item', item);
fixture.detectChanges(); fixture.detectChanges();
let emittedItem: Item | undefined; let emittedItem: Item | undefined;
fixture.componentInstance.selected.subscribe((i) => (emittedItem = i)); fixture.componentInstance.selected.subscribe((i) => (emittedItem = i));
fixture.nativeElement.querySelector("div").click(); fixture.nativeElement.querySelector('div').click();
expect(emittedItem).toEqual(item); expect(emittedItem).toEqual(item);
}); });
}); });
``` ```
@@ -377,36 +372,36 @@ describe("ItemCmpt", () => {
### Using fakeAsync ### Using fakeAsync
```typescript ```typescript
import { fakeAsync, tick, flush } from "@angular/core/testing"; import { fakeAsync, tick, flush } from '@angular/core/testing';
it("should debounce search", fakeAsync(() => { it('should debounce search', fakeAsync(() => {
const fixture = TestBed.createComponent(SearchCmpt); const fixture = TestBed.createComponent(SearchCmpt);
fixture.detectChanges(); fixture.detectChanges();
fixture.componentInstance.query.set("test"); fixture.componentInstance.query.set('test');
tick(300); // Advance time for debounce tick(300); // Advance time for debounce
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.results().length).toBeGreaterThan(0); expect(fixture.componentInstance.results().length).toBeGreaterThan(0);
flush(); // Flush remaining timers flush(); // Flush remaining timers
})); }));
``` ```
### Using waitForAsync ### Using waitForAsync
```typescript ```typescript
import { waitForAsync } from "@angular/core/testing"; import { waitForAsync } from '@angular/core/testing';
it("should load data", waitForAsync(() => { it('should load data', waitForAsync(() => {
const fixture = TestBed.createComponent(DataCmpt); const fixture = TestBed.createComponent(DataCmpt);
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.data()).toBeDefined();
}); fixture.whenStable().then(() => {
fixture.detectChanges();
expect(fixture.componentInstance.data()).toBeDefined();
});
})); }));
``` ```
@@ -414,43 +409,43 @@ it("should load data", waitForAsync(() => {
```typescript ```typescript
@Component({ @Component({
template: ` template: `
@if (userResource.isLoading()) { @if (userResource.isLoading()) {
<p>Loading...</p> <p>Loading...</p>
} @else if (userResource.hasValue()) { } @else if (userResource.hasValue()) {
<p>{{ userResource.value().name }}</p> <p>{{ userResource.value().name }}</p>
} }
`, `,
}) })
export class UserCmpt { export class UserCmpt {
userId = signal("1"); userId = signal('1');
userResource = httpResource<User>(() => `/api/users/${this.userId()}`); userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
} }
describe("UserCmpt", () => { describe('UserCmpt', () => {
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [UserCmpt], imports: [UserCmpt],
providers: [provideHttpClient(), provideHttpClientTesting()], providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents(); }).compileComponents();
httpMock = TestBed.inject(HttpTestingController); httpMock = TestBed.inject(HttpTestingController);
}); });
it("should display user name after loading", () => { it('should display user name after loading', () => {
const fixture = TestBed.createComponent(UserCmpt); const fixture = TestBed.createComponent(UserCmpt);
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Loading"); expect(fixture.nativeElement.textContent).toContain('Loading');
const req = httpMock.expectOne("/api/users/1"); const req = httpMock.expectOne('/api/users/1');
req.flush({ id: "1", name: "John Doe" }); req.flush({ id: '1', name: 'John Doe' });
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("John Doe"); expect(fixture.nativeElement.textContent).toContain('John Doe');
}); });
}); });
``` ```
File diff suppressed because it is too large Load Diff
@@ -17,59 +17,56 @@
```typescript ```typescript
// Jasmine // Jasmine
const spy = jasmine.createSpy("callback"); const spy = jasmine.createSpy('callback');
spy.and.returnValue("value"); spy.and.returnValue('value');
expect(spy).toHaveBeenCalledWith("arg"); expect(spy).toHaveBeenCalledWith('arg');
// Vitest // Vitest
const spy = vi.fn(); const spy = vi.fn();
spy.mockReturnValue("value"); spy.mockReturnValue('value');
expect(spy).toHaveBeenCalledWith("arg"); expect(spy).toHaveBeenCalledWith('arg');
``` ```
### SpyOn Migration ### SpyOn Migration
```typescript ```typescript
// Jasmine // Jasmine
spyOn(service, "method").and.returnValue(of(data)); spyOn(service, 'method').and.returnValue(of(data));
// Vitest // Vitest
vi.spyOn(service, "method").mockReturnValue(of(data)); vi.spyOn(service, 'method').mockReturnValue(of(data));
``` ```
### createSpyObj Migration ### createSpyObj Migration
```typescript ```typescript
// Jasmine // Jasmine
const mockService = jasmine.createSpyObj("UserService", [ const mockService = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
"getUser", mockService.getUser.and.returnValue(of({ id: '1', name: 'Test' }));
"updateUser",
]);
mockService.getUser.and.returnValue(of({ id: "1", name: "Test" }));
// Vitest // Vitest
const mockService = { const mockService = {
getUser: vi.fn(), getUser: vi.fn(),
updateUser: vi.fn(), updateUser: vi.fn(),
}; };
mockService.getUser.mockReturnValue(of({ id: "1", name: "Test" })); mockService.getUser.mockReturnValue(of({ id: '1', name: 'Test' }));
``` ```
### Async Testing Migration ### Async Testing Migration
```typescript ```typescript
// Jasmine - using done callback // Jasmine - using done callback
it("should load data", (done) => { it('should load data', (done) => {
service.loadData().subscribe((data) => { service.loadData().subscribe((data) => {
expect(data).toBeDefined(); expect(data).toBeDefined();
done(); done();
}); });
}); });
// Vitest - using async/await // Vitest - using async/await
it("should load data", async () => { it('should load data', async () => {
const data = await firstValueFrom(service.loadData()); const data = await firstValueFrom(service.loadData());
expect(data).toBeDefined(); expect(data).toBeDefined();
}); });
``` ```
@@ -93,19 +90,19 @@ vi.useRealTimers();
```json ```json
{ {
"projects": { "projects": {
"your-app": { "your-app": {
"architect": { "architect": {
"test": { "test": {
"builder": "@angular/build:unit-test", "builder": "@angular/build:unit-test",
"options": { "options": {
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build" "buildTarget": "your-app:build"
} }
}
}
} }
}
} }
}
} }
``` ```
@@ -113,11 +110,11 @@ vi.useRealTimers();
```json ```json
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"types": ["vitest/globals"] "types": ["vitest/globals"]
}, },
"include": ["src/**/*.spec.ts"] "include": ["src/**/*.spec.ts"]
} }
``` ```
@@ -126,24 +123,19 @@ vi.useRealTimers();
For advanced configuration, create a `vite.config.ts`: For advanced configuration, create a `vite.config.ts`:
```typescript ```typescript
import { defineConfig } from "vitest/config"; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: "jsdom", environment: 'jsdom',
include: ["src/**/*.spec.ts"], include: ['src/**/*.spec.ts'],
coverage: { coverage: {
provider: "v8", provider: 'v8',
reporter: ["text", "html", "lcov"], reporter: ['text', 'html', 'lcov'],
exclude: [ exclude: ['node_modules/', 'src/test-setup.ts', '**/*.spec.ts', '**/*.d.ts'],
"node_modules/", },
"src/test-setup.ts",
"**/*.spec.ts",
"**/*.d.ts",
],
}, },
},
}); });
``` ```
+44 -44
View File
@@ -204,42 +204,42 @@ ng lint --fix
```json ```json
{ {
"projects": { "projects": {
"my-app": { "my-app": {
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular-devkit/build-angular:application",
"options": { "options": {
"outputPath": "dist/my-app", "outputPath": "dist/my-app",
"index": "src/index.html", "index": "src/index.html",
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": ["zone.js"], "polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"assets": ["{ \"glob\": \"**/*\", \"input\": \"public\" }"], "assets": ["{ \"glob\": \"**/*\", \"input\": \"public\" }"],
"styles": ["src/styles.scss"], "styles": ["src/styles.scss"],
"scripts": [] "scripts": []
}, },
"configurations": { "configurations": {
"production": { "production": {
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "500kB",
"maximumError": "1MB" "maximumError": "1MB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
} }
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
} }
}
} }
}
} }
}
} }
``` ```
@@ -248,14 +248,14 @@ ng lint --fix
```typescript ```typescript
// src/environments/environment.ts // src/environments/environment.ts
export const environment = { export const environment = {
production: false, production: false,
apiUrl: "http://localhost:3000/api", apiUrl: 'http://localhost:3000/api',
}; };
// src/environments/environment.prod.ts // src/environments/environment.prod.ts
export const environment = { export const environment = {
production: true, production: true,
apiUrl: "https://api.example.com", apiUrl: 'https://api.example.com',
}; };
``` ```
@@ -263,16 +263,16 @@ Configure in angular.json:
```json ```json
{ {
"configurations": { "configurations": {
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
"replace": "src/environments/environment.ts", "replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts" "with": "src/environments/environment.prod.ts"
}
]
} }
]
} }
}
} }
``` ```
@@ -25,30 +25,21 @@ schematics blank --name=my-schematics
```typescript ```typescript
// src/my-component/index.ts // src/my-component/index.ts
import { import { Rule, SchematicContext, Tree, apply, url, template, move, mergeWith } from '@angular-devkit/schematics';
Rule, import { strings } from '@angular-devkit/core';
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 { export function myComponent(options: { name: string; path: string }): Rule {
return (tree: Tree, context: SchematicContext) => { return (tree: Tree, context: SchematicContext) => {
const templateSource = apply(url("./files"), [ const templateSource = apply(url('./files'), [
template({ template({
...options, ...options,
...strings, ...strings,
}), }),
move(options.path), move(options.path),
]); ]);
return mergeWith(templateSource)(tree, context); return mergeWith(templateSource)(tree, context);
}; };
} }
``` ```
@@ -68,23 +59,23 @@ ng generate my-schematics:my-component --name=test --path=src/app
```json ```json
{ {
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "500kB",
"maximumError": "1MB" "maximumError": "1MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "4kB",
"maximumError": "8kB" "maximumError": "8kB"
}, },
{ {
"type": "anyScript", "type": "anyScript",
"maximumWarning": "100kB", "maximumWarning": "100kB",
"maximumError": "200kB" "maximumError": "200kB"
} }
] ]
} }
``` ```
@@ -105,16 +96,14 @@ last 2 Edge versions
```typescript ```typescript
// Lazy load routes for automatic code splitting // Lazy load routes for automatic code splitting
export const routes: Routes = [ export const routes: Routes = [
{ {
path: "admin", path: 'admin',
loadChildren: () => loadChildren: () => import('./admin/admin.routes').then((m) => m.adminRoutes),
import("./admin/admin.routes").then((m) => m.adminRoutes), },
}, {
{ path: 'reports',
path: "reports", loadComponent: () => import('./reports/reports.component').then((m) => m.Reports),
loadComponent: () => },
import("./reports/reports.component").then((m) => m.Reports),
},
]; ];
``` ```
@@ -124,24 +113,20 @@ Ensure proper imports for tree shaking:
```typescript ```typescript
// Good - tree shakeable // Good - tree shakeable
import { map, filter } from "rxjs"; import { map, filter } from 'rxjs';
// Avoid - imports entire library // Avoid - imports entire library
import * as rxjs from "rxjs"; import * as rxjs from 'rxjs';
``` ```
### Preload Strategy ### Preload Strategy
```typescript ```typescript
// app.config.ts // app.config.ts
import { import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
provideRouter,
withPreloading,
PreloadAllModules,
} from "@angular/router";
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withPreloading(PreloadAllModules))], providers: [provideRouter(routes, withPreloading(PreloadAllModules))],
}; };
``` ```
@@ -194,11 +179,11 @@ ng serve admin-app
```json ```json
// projects/shared-ui/ng-package.json // projects/shared-ui/ng-package.json
{ {
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json", "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/shared-ui", "dest": "../../dist/shared-ui",
"lib": { "lib": {
"entryFile": "src/public-api.ts" "entryFile": "src/public-api.ts"
} }
} }
``` ```
@@ -206,11 +191,11 @@ ng serve admin-app
```typescript ```typescript
// After building library: ng build shared-ui // After building library: ng build shared-ui
import { Button } from "shared-ui"; import { Button } from 'shared-ui';
@Component({ @Component({
imports: [Button], imports: [Button],
template: `<lib-button>Click</lib-button>`, template: `<lib-button>Click</lib-button>`,
}) })
export class App {} export class App {}
``` ```
@@ -224,40 +209,40 @@ export class App {}
name: CI name: CI
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: '20'
cache: "npm" cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Test - name: Test
run: npm run test -- --watch=false --browsers=ChromeHeadless --code-coverage run: npm run test -- --watch=false --browsers=ChromeHeadless --code-coverage
- name: Build - name: Build
run: npm run build -- -c production run: npm run build -- -c production
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v3
with: with:
files: ./coverage/lcov.info files: ./coverage/lcov.info
``` ```
### GitLab CI ### GitLab CI
@@ -267,33 +252,33 @@ jobs:
image: node:20 image: node:20
cache: cache:
paths: paths:
- node_modules/ - node_modules/
- .angular/cache/ - .angular/cache/
stages: stages:
- install - install
- test - test
- build - build
install: install:
stage: install stage: install
script: script:
- npm ci - npm ci
test: test:
stage: test stage: test
script: script:
- npm run lint - npm run lint
- npm run test -- --watch=false --browsers=ChromeHeadless - npm run test -- --watch=false --browsers=ChromeHeadless
build: build:
stage: build stage: build
script: script:
- npm run build -- -c production - npm run build -- -c production
artifacts: artifacts:
paths: paths:
- dist/ - dist/
``` ```
## Path Aliases ## Path Aliases
@@ -302,16 +287,16 @@ build:
```json ```json
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@app/*": ["src/app/*"], "@app/*": ["src/app/*"],
"@env/*": ["src/environments/*"], "@env/*": ["src/environments/*"],
"@shared/*": ["src/app/shared/*"], "@shared/*": ["src/app/shared/*"],
"@features/*": ["src/app/features/*"], "@features/*": ["src/app/features/*"],
"@core/*": ["src/app/core/*"] "@core/*": ["src/app/core/*"]
}
} }
}
} }
``` ```
@@ -319,10 +304,10 @@ build:
```typescript ```typescript
// Instead of relative imports // Instead of relative imports
import { User } from "../../../core/services/user.service"; import { User } from '../../../core/services/user.service';
// Use path alias // Use path alias
import { User } from "@core/services/user.service"; import { User } from '@core/services/user.service';
``` ```
## Proxy Configuration ## Proxy Configuration
@@ -332,18 +317,18 @@ import { User } from "@core/services/user.service";
```json ```json
// proxy.conf.json // proxy.conf.json
{ {
"/api": { "/api": {
"target": "http://localhost:3000", "target": "http://localhost:3000",
"secure": false, "secure": false,
"changeOrigin": true "changeOrigin": true
}, },
"/auth": { "/auth": {
"target": "http://localhost:4000", "target": "http://localhost:4000",
"secure": false, "secure": false,
"pathRewrite": { "pathRewrite": {
"^/auth": "" "^/auth": ""
}
} }
}
} }
``` ```
@@ -351,11 +336,11 @@ import { User } from "@core/services/user.service";
```json ```json
{ {
"serve": { "serve": {
"options": { "options": {
"proxyConfig": "proxy.conf.json" "proxyConfig": "proxy.conf.json"
}
} }
}
} }
``` ```
@@ -371,14 +356,14 @@ ng serve --proxy-config proxy.conf.json
```json ```json
{ {
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular-devkit/build-angular:application",
"options": { "options": {
"browser": "src/main.ts" "browser": "src/main.ts"
} }
}
} }
}
} }
``` ```
@@ -391,17 +376,17 @@ ng add @angular/ssr
```json ```json
{ {
"architect": { "architect": {
"build": { "build": {
"options": { "options": {
"server": "src/main.server.ts", "server": "src/main.server.ts",
"prerender": true, "prerender": true,
"ssr": { "ssr": {
"entry": "server.ts" "entry": "server.ts"
}
}
} }
}
} }
}
} }
``` ```
@@ -411,19 +396,19 @@ ng add @angular/ssr
```json ```json
{ {
"configurations": { "configurations": {
"development": { "development": {
"sourceMap": true "sourceMap": true
}, },
"production": { "production": {
"sourceMap": { "sourceMap": {
"scripts": true, "scripts": true,
"styles": false, "styles": false,
"hidden": true, "hidden": true,
"vendor": false "vendor": false
} }
}
} }
}
} }
``` ```
@@ -447,16 +432,16 @@ ng test --browsers=Chrome
```json ```json
{ {
"scripts": { "scripts": {
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"build:prod": "ng build -c production", "build:prod": "ng build -c production",
"test": "ng test", "test": "ng test",
"test:ci": "ng test --watch=false --browsers=ChromeHeadless --code-coverage", "test:ci": "ng test --watch=false --browsers=ChromeHeadless --code-coverage",
"lint": "ng lint", "lint": "ng lint",
"lint:fix": "ng lint --fix", "lint:fix": "ng lint --fix",
"analyze": "ng build -c production --stats-json && npx esbuild-visualizer --metadata dist/my-app/browser/stats.json --open", "analyze": "ng build -c production --stats-json && npx esbuild-visualizer --metadata dist/my-app/browser/stats.json --open",
"update": "ng update" "update": "ng update"
} }
} }
``` ```
+27 -27
View File
@@ -2,41 +2,41 @@ kind: pipeline
type: kubernetes type: kubernetes
name: pr-check name: pr-check
trigger: trigger:
event: event:
- pull_request - pull_request
steps: steps:
- name: build-angular - name: build-angular
image: node:22-alpine image: node:22-alpine
commands: commands:
- npm ci - npm ci
- npm run build - npm run build
--- ---
kind: pipeline kind: pipeline
type: kubernetes type: kubernetes
name: deploy name: deploy
trigger: trigger:
branch: branch:
- main - main
event: event:
- push - push
steps: steps:
- name: build-angular - name: build-angular
image: node:22-alpine image: node:22-alpine
commands: commands:
- npm ci - npm ci
- npm run build - npm run build
- name: build-and-push - name: build-and-push
image: plugins/docker image: plugins/docker
settings: settings:
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:
- ${DRONE_COMMIT_SHA} - ${DRONE_COMMIT_SHA}
username: username:
from_secret: gitea_username from_secret: gitea_username
password: password:
from_secret: gitea_password from_secret: gitea_password
+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"]
}
+71 -71
View File
@@ -1,78 +1,78 @@
{ {
"$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"] "schematicCollections": ["angular-eslint"]
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"ngrx-playground": { "ngrx-playground": {
"projectType": "application", "projectType": "application",
"schematics": {}, "schematics": {},
"root": "", "root": "",
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "app", "prefix": "app",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"browser": "src/main.ts", "browser": "src/main.ts",
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"assets": [ "assets": [
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
} }
], ],
"styles": ["src/styles.css"] "styles": ["src/styles.css"]
}, },
"configurations": { "configurations": {
"production": { "production": {
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "500kB",
"maximumError": "1MB" "maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
}, },
{ "serve": {
"type": "anyComponentStyle", "builder": "@angular/build:dev-server",
"maximumWarning": "4kB", "configurations": {
"maximumError": "8kB" "production": {
"buildTarget": "ngrx-playground:build:production"
},
"development": {
"buildTarget": "ngrx-playground:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
} }
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
} }
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "ngrx-playground:build:production"
},
"development": {
"buildTarget": "ngrx-playground:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
} }
}
} }
}
} }
+30 -38
View File
@@ -1,44 +1,36 @@
// @ts-check // @ts-check
const eslint = require("@eslint/js"); const eslint = require('@eslint/js');
const { defineConfig } = require("eslint/config"); const { defineConfig } = require('eslint/config');
const tseslint = require("typescript-eslint"); const tseslint = require('typescript-eslint');
const angular = require("angular-eslint"); const angular = require('angular-eslint');
module.exports = defineConfig([ module.exports = defineConfig([
{ {
files: ["**/*.ts"], files: ['**/*.ts'],
extends: [ extends: [eslint.configs.recommended, tseslint.configs.recommended, tseslint.configs.stylistic, angular.configs.tsRecommended],
eslint.configs.recommended, processor: angular.processInlineTemplates,
tseslint.configs.recommended, rules: {
tseslint.configs.stylistic, '@angular-eslint/directive-selector': [
angular.configs.tsRecommended, 'error',
], {
processor: angular.processInlineTemplates, type: 'attribute',
rules: { prefix: 'app',
"@angular-eslint/directive-selector": [ style: 'camelCase',
"error", },
{ ],
type: "attribute", '@angular-eslint/component-selector': [
prefix: "app", 'error',
style: "camelCase", {
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
}, },
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case",
},
],
}, },
}, {
{ files: ['**/*.html'],
files: ["**/*.html"], extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
extends: [ rules: {},
angular.configs.templateRecommended, },
angular.configs.templateAccessibility,
],
rules: {},
},
]); ]);
+10398 -10359
View File
File diff suppressed because it is too large Load Diff
+41 -39
View File
@@ -1,41 +1,43 @@
{ {
"name": "ngrx-playground", "name": "ngrx-playground",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"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" "lint": "ng lint"
}, },
"private": true, "private": true,
"packageManager": "npm@11.6.2", "packageManager": "npm@11.6.2",
"dependencies": { "dependencies": {
"@angular/common": "^21.2.0", "@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0", "@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0", "@angular/core": "^21.2.0",
"@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/eslint-plugin": "^17.2.0",
"@ngrx/signals": "^21.0.1", "@ngrx/signals": "^21.0.1",
"@ngrx/store": "^21.0.1", "@ngrx/store": "^21.0.1",
"@ngrx/store-devtools": "^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/build": "^21.2.1", "@angular-architects/ngrx-toolkit": "^21.0.1",
"@angular/cli": "^21.2.1", "@angular/build": "^21.2.1",
"@angular/compiler-cli": "^21.2.0", "@angular/cli": "^21.2.1",
"@eslint/js": "^10.0.1", "@angular/compiler-cli": "^21.2.0",
"angular-eslint": "21.3.0", "@eslint/js": "^10.0.1",
"eslint": "^10.0.2", "angular-eslint": "21.3.0",
"jsdom": "^28.0.0", "eslint": "^10.0.2",
"prettier": "3.8.1", "jsdom": "^28.0.0",
"typescript": "~5.9.2", "prettier": "3.8.1",
"typescript-eslint": "8.56.1", "prettier-plugin-organize-attributes": "^1.0.0",
"vitest": "^4.0.8" "typescript": "~5.9.2",
} "typescript-eslint": "8.56.1",
"vitest": "^4.0.8"
}
} }
+57 -57
View File
@@ -1,60 +1,60 @@
{ {
"version": 1, "version": 1,
"skills": { "skills": {
"angular-best-practices-ngrx": { "angular-best-practices-ngrx": {
"source": "alfredoperez/angular-best-practices", "source": "alfredoperez/angular-best-practices",
"sourceType": "github", "sourceType": "github",
"computedHash": "3ced41dfe6d6738c27627675a4a8f48228badcd6470654173cc235d04d812a6f" "computedHash": "3ced41dfe6d6738c27627675a4a8f48228badcd6470654173cc235d04d812a6f"
}, },
"angular-component": { "angular-component": {
"source": "analogjs/angular-skills", "source": "analogjs/angular-skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "2789fdf11e2a70832ce2e6e79954018325a010ebdd45c5d4b1e7bfdf44679b7c" "computedHash": "2789fdf11e2a70832ce2e6e79954018325a010ebdd45c5d4b1e7bfdf44679b7c"
}, },
"angular-di": { "angular-di": {
"source": "analogjs/angular-skills", "source": "analogjs/angular-skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "4d3dd9ecceec5642eea118aede824501bdec537b6b8735564a68787ec9617b75" "computedHash": "4d3dd9ecceec5642eea118aede824501bdec537b6b8735564a68787ec9617b75"
}, },
"angular-directives": { "angular-directives": {
"source": "analogjs/angular-skills", "source": "analogjs/angular-skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "b8ce636215ec0792839fe57c3632b475957bed1ebc404e9a1c54222e0e506bfa" "computedHash": "b8ce636215ec0792839fe57c3632b475957bed1ebc404e9a1c54222e0e506bfa"
}, },
"angular-forms": { "angular-forms": {
"source": "analogjs/angular-skills", "source": "analogjs/angular-skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "a2e28f7c7c1b9b13f0a8212f403515886f9d23bf13732a355b1f5d01f5cc9d69" "computedHash": "a2e28f7c7c1b9b13f0a8212f403515886f9d23bf13732a355b1f5d01f5cc9d69"
}, },
"angular-http": { "angular-http": {
"source": "analogjs/angular-skills", "source": "analogjs/angular-skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "ad8821177929ea09eb94e7fc386d842c76c7f39b6c4d7aa3176ba3596dbabecc" "computedHash": "ad8821177929ea09eb94e7fc386d842c76c7f39b6c4d7aa3176ba3596dbabecc"
}, },
"angular-routing": { "angular-routing": {
"source": "analogjs/angular-skills", "source": "analogjs/angular-skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "e54b8803da246042525e67effe7ea2a9dc26d5a50e857125793f579c7027aef3" "computedHash": "e54b8803da246042525e67effe7ea2a9dc26d5a50e857125793f579c7027aef3"
}, },
"angular-signals": { "angular-signals": {
"source": "analogjs/angular-skills", "source": "analogjs/angular-skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "ddedef1232295f200b966778bc36d16fc07d2113ad1dbc73d75d14eb544953bc" "computedHash": "ddedef1232295f200b966778bc36d16fc07d2113ad1dbc73d75d14eb544953bc"
}, },
"angular-testing": { "angular-testing": {
"source": "analogjs/angular-skills", "source": "analogjs/angular-skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "66adf01ccb30305e179ce41ff76ded6b60dbb5909bd889cc350cdd13b6933130" "computedHash": "66adf01ccb30305e179ce41ff76ded6b60dbb5909bd889cc350cdd13b6933130"
}, },
"angular-tooling": { "angular-tooling": {
"source": "analogjs/angular-skills", "source": "analogjs/angular-skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "2a67063279e8ac1b5cf3d9c4e9ed4652508564640c1fc59642857d4f6d20db11" "computedHash": "2a67063279e8ac1b5cf3d9c4e9ed4652508564640c1fc59642857d4f6d20db11"
}, },
"frontend-design": { "frontend-design": {
"source": "anthropics/skills", "source": "anthropics/skills",
"sourceType": "github", "sourceType": "github",
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67" "computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
}
} }
}
} }
+4 -16
View File
@@ -1,19 +1,7 @@
import { import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
ApplicationConfig, import { provideRouter } from '@angular/router';
provideBrowserGlobalErrorListeners, import { routes } from './app.routes';
isDevMode,
} from "@angular/core";
import { provideRouter } from "@angular/router";
import { routes } from "./app.routes";
import { provideStore } from "@ngrx/store";
import { provideStoreDevtools } from "@ngrx/store-devtools";
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideStore(),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
],
}; };
+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;
}
}
+27 -4
View File
@@ -1,4 +1,27 @@
<h2>Counter: {{ store.count() }}</h2> <div class="shell">
<button (click)="store.increment()">Increment</button> <header class="shell-header">
<button (click)="store.decrement()">Decrement</button> <div class="brand-block">
<button (click)="store.reset()">Reset</button> <p class="brand-kicker">Angular + NgRx Signals</p>
<div class="brand-row">
<h1>NGRX Playground</h1>
<span class="version-pill">Signal Store</span>
</div>
<p class="brand-copy">
Feature-first state architecture with persistence, derived state,
and production-ready debugging.
</p>
</div>
<nav aria-label="Primary">
<a
routerLink="/tasks"
routerLinkActive="active"
>Tasks</a
>
</nav>
</header>
<main>
<router-outlet />
</main>
</div>
+16 -2
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',
},
];
+22 -21
View File
@@ -1,25 +1,26 @@
import { TestBed } from "@angular/core/testing"; import { TestBed } from '@angular/core/testing';
import { App } from "./app"; import { appConfig } from './app.config';
import { App } from './app';
describe("App", () => { describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
}).compileComponents(); providers: appConfig.providers,
}); }).compileComponents();
});
it("should create the app", () => { it('should create the app', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance; const app = fixture.componentInstance;
expect(app).toBeTruthy(); expect(app).toBeTruthy();
}); });
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();
const compiled = fixture.nativeElement as HTMLElement; fixture.detectChanges();
expect(compiled.querySelector("h1")?.textContent).toContain( const compiled = fixture.nativeElement as HTMLElement;
"Hello, ngrx-playground", expect(compiled.querySelector('h1')?.textContent).toContain('NGRX Playground');
); });
});
}); });
+8 -8
View File
@@ -1,11 +1,11 @@
import { Component, inject } from "@angular/core"; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CounterStore } from "./core/counter.store"; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({ @Component({
selector: "app-root", selector: 'app-root',
templateUrl: "./app.html", imports: [RouterOutlet, RouterLink, RouterLinkActive],
styleUrl: "./app.css", templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class App { export class App {}
readonly store = inject(CounterStore);
}
-31
View File
@@ -1,31 +0,0 @@
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
// Define the shape of the state
export interface CounterState {
count: number;
}
// Set the initial state
const initialState: CounterState = {
count: 0,
};
// Create the SignalStore
export const CounterStore = signalStore(
{ providedIn: "root" }, // Makes the store available app-wide
withState(initialState), // Adds the state to the store
// Adds methods to update the state
withMethods((store) => ({
increment() {
// Use patchState to immutably update the state
patchState(store, { count: store.count() + 1 });
},
decrement() {
patchState(store, { count: store.count() - 1 });
},
reset() {
patchState(store, initialState);
},
})),
);
@@ -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);
}
}
+15 -10
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
<app-root></app-root> rel="icon"
</body> type="image/x-icon"
href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html> </html>
+3 -3
View File
@@ -1,5 +1,5 @@
import { bootstrapApplication } from "@angular/platform-browser"; 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).catch((err) => console.error(err)); bootstrapApplication(App, appConfig).catch((err) => console.error(err));
+7 -7
View File
@@ -1,11 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"types": [] "types": []
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"] "exclude": ["src/**/*.spec.ts"]
} }
+28 -28
View File
@@ -1,33 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{ {
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"skipLibCheck": true, "skipLibCheck": true,
"isolatedModules": true, "isolatedModules": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "preserve" "module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
}, },
{ "angularCompilerOptions": {
"path": "./tsconfig.spec.json" "enableI18nLegacyMessageIdFormat": false,
} "strictInjectionParameters": true,
] "strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
} }
+6 -6
View File
@@ -1,10 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": ["vitest/globals"] "types": ["vitest/globals"]
}, },
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"] "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
} }