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