feat: add ngrx store implementation #2
@@ -30,7 +30,7 @@ Use `createFeatureSelector` and `createSelector` for memoized state selection. S
|
|||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const selectCounterState = createFeatureSelector<CounterState>("counter");
|
const selectCounterState = createFeatureSelector<CounterState>('counter');
|
||||||
export const selectCount = createSelector(selectCounterState, (s) => s.count);
|
export const selectCount = createSelector(selectCounterState, (s) => s.count);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ description: >-
|
|||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: alfredoperez
|
author: alfredoperez
|
||||||
version: "1.2.0"
|
version: '1.2.0'
|
||||||
tags: [angular, ngrx, state-management, redux]
|
tags: [angular, ngrx, state-management, redux]
|
||||||
globs:
|
globs:
|
||||||
- "**/*.ts"
|
- '**/*.ts'
|
||||||
- "**/*.reducer.ts"
|
- '**/*.reducer.ts'
|
||||||
- "**/*.effects.ts"
|
- '**/*.effects.ts'
|
||||||
- "**/*.selectors.ts"
|
- '**/*.selectors.ts'
|
||||||
---
|
---
|
||||||
|
|
||||||
# Angular NgRx Best Practices
|
# Angular NgRx Best Practices
|
||||||
|
|||||||
@@ -10,24 +10,20 @@ Create standalone components for Angular v20+. Components are standalone by defa
|
|||||||
## Component Structure
|
## Component Structure
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
|
||||||
Component,
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
input,
|
|
||||||
output,
|
|
||||||
computed,
|
|
||||||
} from "@angular/core";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-user-card",
|
selector: 'app-user-card',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
host: {
|
host: {
|
||||||
class: "user-card",
|
'class': 'user-card',
|
||||||
"[class.active]": "isActive()",
|
'[class.active]': 'isActive()',
|
||||||
"(click)": "handleClick()",
|
'(click)': 'handleClick()',
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<img [src]="avatarUrl()" [alt]="name() + ' avatar'" />
|
<img
|
||||||
|
[src]="avatarUrl()"
|
||||||
|
[alt]="name() + ' avatar'" />
|
||||||
<h2>{{ name() }}</h2>
|
<h2>{{ name() }}</h2>
|
||||||
@if (showEmail()) {
|
@if (showEmail()) {
|
||||||
<p>{{ email() }}</p>
|
<p>{{ email() }}</p>
|
||||||
@@ -47,7 +43,7 @@ export class UserCard {
|
|||||||
name = input.required<string>();
|
name = input.required<string>();
|
||||||
|
|
||||||
// Optional input with default
|
// Optional input with default
|
||||||
email = input<string>("");
|
email = input<string>('');
|
||||||
showEmail = input(false);
|
showEmail = input(false);
|
||||||
|
|
||||||
// Input with transform
|
// Input with transform
|
||||||
@@ -78,7 +74,7 @@ count = input(0);
|
|||||||
label = input<string>();
|
label = input<string>();
|
||||||
|
|
||||||
// With alias for template binding
|
// With alias for template binding
|
||||||
size = input("medium", { alias: "buttonSize" });
|
size = input('medium', { alias: 'buttonSize' });
|
||||||
|
|
||||||
// With transform function
|
// With transform function
|
||||||
disabled = input(false, { transform: booleanAttribute });
|
disabled = input(false, { transform: booleanAttribute });
|
||||||
@@ -88,14 +84,14 @@ value = input(0, { transform: numberAttribute });
|
|||||||
## Signal Outputs
|
## Signal Outputs
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { output, outputFromObservable } from "@angular/core";
|
import { output, outputFromObservable } from '@angular/core';
|
||||||
|
|
||||||
// Basic output
|
// Basic output
|
||||||
clicked = output<void>();
|
clicked = output<void>();
|
||||||
selected = output<Item>();
|
selected = output<Item>();
|
||||||
|
|
||||||
// With alias
|
// With alias
|
||||||
valueChange = output<number>({ alias: "change" });
|
valueChange = output<number>({ alias: 'change' });
|
||||||
|
|
||||||
// From Observable (for RxJS interop)
|
// From Observable (for RxJS interop)
|
||||||
scroll$ = new Subject<number>();
|
scroll$ = new Subject<number>();
|
||||||
@@ -112,33 +108,33 @@ Use the `host` object in `@Component`—do NOT use `@HostBinding` or `@HostListe
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-button",
|
selector: 'app-button',
|
||||||
host: {
|
host: {
|
||||||
// Static attributes
|
// Static attributes
|
||||||
role: "button",
|
'role': 'button',
|
||||||
|
|
||||||
// Dynamic class bindings
|
// Dynamic class bindings
|
||||||
"[class.primary]": 'variant() === "primary"',
|
'[class.primary]': 'variant() === "primary"',
|
||||||
"[class.disabled]": "disabled()",
|
'[class.disabled]': 'disabled()',
|
||||||
|
|
||||||
// Dynamic style bindings
|
// Dynamic style bindings
|
||||||
"[style.--btn-color]": "color()",
|
'[style.--btn-color]': 'color()',
|
||||||
|
|
||||||
// Attribute bindings
|
// Attribute bindings
|
||||||
"[attr.aria-disabled]": "disabled()",
|
'[attr.aria-disabled]': 'disabled()',
|
||||||
"[attr.tabindex]": "disabled() ? -1 : 0",
|
'[attr.tabindex]': 'disabled() ? -1 : 0',
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
"(click)": "onClick($event)",
|
'(click)': 'onClick($event)',
|
||||||
"(keydown.enter)": "onClick($event)",
|
'(keydown.enter)': 'onClick($event)',
|
||||||
"(keydown.space)": "onClick($event)",
|
'(keydown.space)': 'onClick($event)',
|
||||||
},
|
},
|
||||||
template: `<ng-content />`,
|
template: `<ng-content />`,
|
||||||
})
|
})
|
||||||
export class Button {
|
export class Button {
|
||||||
variant = input<"primary" | "secondary">("primary");
|
variant = input<'primary' | 'secondary'>('primary');
|
||||||
disabled = input(false, { transform: booleanAttribute });
|
disabled = input(false, { transform: booleanAttribute });
|
||||||
color = input("#007bff");
|
color = input('#007bff');
|
||||||
|
|
||||||
clicked = output<void>();
|
clicked = output<void>();
|
||||||
|
|
||||||
@@ -154,7 +150,7 @@ export class Button {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-card",
|
selector: 'app-card',
|
||||||
template: `
|
template: `
|
||||||
<header>
|
<header>
|
||||||
<ng-content select="[card-header]" />
|
<ng-content select="[card-header]" />
|
||||||
@@ -180,7 +176,7 @@ export class Card {}
|
|||||||
## Lifecycle Hooks
|
## Lifecycle Hooks
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { OnDestroy, OnInit, afterNextRender, afterRender } from "@angular/core";
|
import { OnDestroy, OnInit, afterNextRender, afterRender } from '@angular/core';
|
||||||
|
|
||||||
export class My implements OnInit, OnDestroy {
|
export class My implements OnInit, OnDestroy {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -215,19 +211,17 @@ Components MUST:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-toggle",
|
selector: 'app-toggle',
|
||||||
host: {
|
host: {
|
||||||
role: "switch",
|
'role': 'switch',
|
||||||
"[attr.aria-checked]": "checked()",
|
'[attr.aria-checked]': 'checked()',
|
||||||
"[attr.aria-label]": "label()",
|
'[attr.aria-label]': 'label()',
|
||||||
tabindex: "0",
|
'tabindex': '0',
|
||||||
"(click)": "toggle()",
|
'(click)': 'toggle()',
|
||||||
"(keydown.enter)": "toggle()",
|
'(keydown.enter)': 'toggle()',
|
||||||
"(keydown.space)": "toggle(); $event.preventDefault()",
|
'(keydown.space)': 'toggle(); $event.preventDefault()',
|
||||||
},
|
},
|
||||||
template: `<span class="toggle-track"
|
template: `<span class="toggle-track"><span class="toggle-thumb"></span></span>`,
|
||||||
><span class="toggle-thumb"></span
|
|
||||||
></span>`,
|
|
||||||
})
|
})
|
||||||
export class Toggle {
|
export class Toggle {
|
||||||
label = input.required<string>();
|
label = input.required<string>();
|
||||||
@@ -262,8 +256,7 @@ Use native control flow—do NOT use `*ngIf`, `*ngFor`, `*ngSwitch`.
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- Switch -->
|
<!-- Switch -->
|
||||||
@switch (status()) { @case ('pending') { <span>Pending</span> } @case ('active')
|
@switch (status()) { @case ('pending') { <span>Pending</span> } @case ('active') { <span>Active</span> } @default { <span>Unknown</span> } }
|
||||||
{ <span>Active</span> } @default { <span>Unknown</span> } }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Class and Style Bindings
|
## Class and Style Bindings
|
||||||
@@ -285,13 +278,20 @@ Do NOT use `ngClass` or `ngStyle`. Use direct bindings:
|
|||||||
Use `NgOptimizedImage` for static images:
|
Use `NgOptimizedImage` for static images:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { NgOptimizedImage } from "@angular/common";
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
imports: [NgOptimizedImage],
|
imports: [NgOptimizedImage],
|
||||||
template: `
|
template: `
|
||||||
<img ngSrc="/assets/hero.jpg" width="800" height="600" priority />
|
<img
|
||||||
<img [ngSrc]="imageUrl()" width="200" height="200" />
|
ngSrc="/assets/hero.jpg"
|
||||||
|
width="800"
|
||||||
|
height="600"
|
||||||
|
priority />
|
||||||
|
<img
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
[ngSrc]="imageUrl()" />
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class Hero {
|
export class Hero {
|
||||||
|
|||||||
@@ -14,15 +14,19 @@
|
|||||||
For two-way binding with `[(value)]` syntax:
|
For two-way binding with `[(value)]` syntax:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Component, model } from "@angular/core";
|
import { Component, model } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-slider",
|
selector: 'app-slider',
|
||||||
host: {
|
host: {
|
||||||
"(input)": "onInput($event)",
|
'(input)': 'onInput($event)',
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<input type="range" [value]="value()" [min]="min()" [max]="max()" />
|
<input
|
||||||
|
type="range"
|
||||||
|
[value]="value()"
|
||||||
|
[min]="min()"
|
||||||
|
[max]="max()" />
|
||||||
<span>{{ value() }}</span>
|
<span>{{ value() }}</span>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -52,12 +56,14 @@ value = model.required<number>();
|
|||||||
Query elements and components in the template:
|
Query elements and components in the template:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Component, viewChild, viewChildren, ElementRef } from "@angular/core";
|
import { Component, viewChild, viewChildren, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-gallery",
|
selector: 'app-gallery',
|
||||||
template: `
|
template: `
|
||||||
<div #container class="gallery">
|
<div
|
||||||
|
#container
|
||||||
|
class="gallery">
|
||||||
@for (image of images(); track image.id) {
|
@for (image of images(); track image.id) {
|
||||||
<app-image-card [image]="image" />
|
<app-image-card [image]="image" />
|
||||||
}
|
}
|
||||||
@@ -68,7 +74,7 @@ export class Gallery {
|
|||||||
images = input.required<Image[]>();
|
images = input.required<Image[]>();
|
||||||
|
|
||||||
// Query single element
|
// Query single element
|
||||||
container = viewChild.required<ElementRef<HTMLDivElement>>("container");
|
container = viewChild.required<ElementRef<HTMLDivElement>>('container');
|
||||||
|
|
||||||
// Query single component (optional)
|
// Query single component (optional)
|
||||||
firstCard = viewChild(ImageCard);
|
firstCard = viewChild(ImageCard);
|
||||||
@@ -83,20 +89,16 @@ export class Gallery {
|
|||||||
Query projected content:
|
Query projected content:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import { Component, contentChild, contentChildren, effect, signal } from '@angular/core';
|
||||||
Component,
|
|
||||||
contentChild,
|
|
||||||
contentChildren,
|
|
||||||
effect,
|
|
||||||
signal,
|
|
||||||
} from "@angular/core";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-tabs",
|
selector: 'app-tabs',
|
||||||
template: `
|
template: `
|
||||||
<div class="tab-headers">
|
<div class="tab-headers">
|
||||||
@for (tab of tabs(); track tab.label()) {
|
@for (tab of tabs(); track tab.label()) {
|
||||||
<button [class.active]="tab === activeTab()" (click)="selectTab(tab)">
|
<button
|
||||||
|
[class.active]="tab === activeTab()"
|
||||||
|
(click)="selectTab(tab)">
|
||||||
{{ tab.label() }}
|
{{ tab.label() }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -111,7 +113,7 @@ export class Tabs {
|
|||||||
tabs = contentChildren(Tab);
|
tabs = contentChildren(Tab);
|
||||||
|
|
||||||
// Query single projected element
|
// Query single projected element
|
||||||
header = contentChild("tabHeader");
|
header = contentChild('tabHeader');
|
||||||
|
|
||||||
activeTab = signal<Tab | undefined>(undefined);
|
activeTab = signal<Tab | undefined>(undefined);
|
||||||
|
|
||||||
@@ -131,11 +133,11 @@ export class Tabs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-tab",
|
selector: 'app-tab',
|
||||||
template: `<ng-content />`,
|
template: `<ng-content />`,
|
||||||
host: {
|
host: {
|
||||||
"[class.active]": "isActive()",
|
'[class.active]': 'isActive()',
|
||||||
"[style.display]": 'isActive() ? "block" : "none"',
|
'[style.display]': 'isActive() ? "block" : "none"',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Tab {
|
export class Tab {
|
||||||
@@ -149,11 +151,11 @@ export class Tab {
|
|||||||
Use `inject()` function instead of constructor injection:
|
Use `inject()` function instead of constructor injection:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Component, inject } from "@angular/core";
|
import { Component, inject } from '@angular/core';
|
||||||
import { Router } from "@angular/router";
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-dashboard",
|
selector: 'app-dashboard',
|
||||||
template: `...`,
|
template: `...`,
|
||||||
})
|
})
|
||||||
export class Dashboard {
|
export class Dashboard {
|
||||||
@@ -168,7 +170,7 @@ export class Dashboard {
|
|||||||
private localService = inject(Local, { self: true });
|
private localService = inject(Local, { self: true });
|
||||||
|
|
||||||
navigateToProfile() {
|
navigateToProfile() {
|
||||||
this.router.navigate(["/profile"]);
|
this.router.navigate(['/profile']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -180,15 +182,17 @@ export class Dashboard {
|
|||||||
```typescript
|
```typescript
|
||||||
// Parent
|
// Parent
|
||||||
@Component({
|
@Component({
|
||||||
template: `<app-child [data]="parentData()" [config]="config" />`,
|
template: `<app-child
|
||||||
|
[data]="parentData()"
|
||||||
|
[config]="config" />`,
|
||||||
})
|
})
|
||||||
export class Parent {
|
export class Parent {
|
||||||
parentData = signal({ name: "Test" });
|
parentData = signal({ name: 'Test' });
|
||||||
config = { theme: "dark" };
|
config = { theme: 'dark' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Child
|
// Child
|
||||||
@Component({ selector: "app-child" })
|
@Component({ selector: 'app-child' })
|
||||||
export class Child {
|
export class Child {
|
||||||
data = input.required<Data>();
|
data = input.required<Data>();
|
||||||
config = input<Config>();
|
config = input<Config>();
|
||||||
@@ -200,14 +204,14 @@ export class Child {
|
|||||||
```typescript
|
```typescript
|
||||||
// Child
|
// Child
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-child",
|
selector: 'app-child',
|
||||||
template: `<button (click)="save()">Save</button>`,
|
template: `<button (click)="save()">Save</button>`,
|
||||||
})
|
})
|
||||||
export class Child {
|
export class Child {
|
||||||
saved = output<Data>();
|
saved = output<Data>();
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
this.saved.emit({ id: 1, name: "Item" });
|
this.saved.emit({ id: 1, name: 'Item' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +221,7 @@ export class Child {
|
|||||||
})
|
})
|
||||||
export class Parent {
|
export class Parent {
|
||||||
onSaved(data: Data) {
|
onSaved(data: Data) {
|
||||||
console.log("Saved:", data);
|
console.log('Saved:', data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -226,14 +230,12 @@ export class Parent {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Shared state service
|
// Shared state service
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class Cart {
|
export class Cart {
|
||||||
private items = signal<CartItem[]>([]);
|
private items = signal<CartItem[]>([]);
|
||||||
|
|
||||||
readonly items$ = this.items.asReadonly();
|
readonly items$ = this.items.asReadonly();
|
||||||
readonly total = computed(() =>
|
readonly total = computed(() => this.items().reduce((sum, item) => sum + item.price, 0));
|
||||||
this.items().reduce((sum, item) => sum + item.price, 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
addItem(item: CartItem) {
|
addItem(item: CartItem) {
|
||||||
this.items.update((items) => [...items, item]);
|
this.items.update((items) => [...items, item]);
|
||||||
@@ -314,13 +316,13 @@ export class Post {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appHighlight]",
|
selector: '[appHighlight]',
|
||||||
host: {
|
host: {
|
||||||
"[style.backgroundColor]": "color()",
|
'[style.backgroundColor]': 'color()',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Highlight {
|
export class Highlight {
|
||||||
color = input("yellow", { alias: "appHighlight" });
|
color = input('yellow', { alias: 'appHighlight' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage on component
|
// Usage on component
|
||||||
@@ -335,7 +337,7 @@ export class Page {}
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-error-boundary",
|
selector: 'app-error-boundary',
|
||||||
template: `
|
template: `
|
||||||
@if (hasError()) {
|
@if (hasError()) {
|
||||||
<div class="error">
|
<div class="error">
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ Configure and use dependency injection in Angular v20+ with `inject()` and provi
|
|||||||
Prefer `inject()` over constructor injection:
|
Prefer `inject()` over constructor injection:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Component, inject } from "@angular/core";
|
import { Component, inject } from '@angular/core';
|
||||||
import { HttpClient } from "@angular/common/http";
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { User } from "./user.service";
|
import { User } from './user.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-user-list",
|
selector: 'app-user-list',
|
||||||
template: `...`,
|
template: `...`,
|
||||||
})
|
})
|
||||||
export class UserList {
|
export class UserList {
|
||||||
@@ -35,11 +35,11 @@ export class UserList {
|
|||||||
### Injectable Services
|
### Injectable Services
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Injectable, inject, signal } from "@angular/core";
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { HttpClient } from "@angular/common/http";
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: "root", // Singleton at root level
|
providedIn: 'root', // Singleton at root level
|
||||||
})
|
})
|
||||||
export class User {
|
export class User {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
@@ -48,7 +48,7 @@ export class User {
|
|||||||
readonly users$ = this.users.asReadonly();
|
readonly users$ = this.users.asReadonly();
|
||||||
|
|
||||||
async loadUsers() {
|
async loadUsers() {
|
||||||
const users = await firstValueFrom(this.http.get<User[]>("/api/users"));
|
const users = await firstValueFrom(this.http.get<User[]>('/api/users'));
|
||||||
this.users.set(users);
|
this.users.set(users);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ export class User {
|
|||||||
```typescript
|
```typescript
|
||||||
// Recommended: providedIn
|
// Recommended: providedIn
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: "root",
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class Auth {}
|
export class Auth {}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-editor",
|
selector: 'app-editor',
|
||||||
providers: [EditorState], // New instance for each component
|
providers: [EditorState], // New instance for each component
|
||||||
template: `...`,
|
template: `...`,
|
||||||
})
|
})
|
||||||
@@ -89,11 +89,11 @@ export class Editor {
|
|||||||
```typescript
|
```typescript
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: "admin",
|
path: 'admin',
|
||||||
providers: [Admin], // Shared within this route tree
|
providers: [Admin], // Shared within this route tree
|
||||||
children: [
|
children: [
|
||||||
{ path: "", component: AdminDashboard },
|
{ path: '', component: AdminDashboard },
|
||||||
{ path: "users", component: AdminUsers },
|
{ path: 'users', component: AdminUsers },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -104,10 +104,10 @@ export const routes: Routes = [
|
|||||||
### Creating Tokens
|
### Creating Tokens
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { InjectionToken } from "@angular/core";
|
import { InjectionToken } from '@angular/core';
|
||||||
|
|
||||||
// Simple value token
|
// Simple value token
|
||||||
export const API_URL = new InjectionToken<string>("API_URL");
|
export const API_URL = new InjectionToken<string>('API_URL');
|
||||||
|
|
||||||
// Object token
|
// Object token
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
@@ -118,16 +118,16 @@ export interface AppConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_CONFIG = new InjectionToken<AppConfig>("APP_CONFIG");
|
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
|
||||||
|
|
||||||
// Token with factory (self-providing)
|
// Token with factory (self-providing)
|
||||||
export const WINDOW = new InjectionToken<Window>("Window", {
|
export const WINDOW = new InjectionToken<Window>('Window', {
|
||||||
providedIn: "root",
|
providedIn: 'root',
|
||||||
factory: () => window,
|
factory: () => window,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LOCAL_STORAGE = new InjectionToken<Storage>("LocalStorage", {
|
export const LOCAL_STORAGE = new InjectionToken<Storage>('LocalStorage', {
|
||||||
providedIn: "root",
|
providedIn: 'root',
|
||||||
factory: () => localStorage,
|
factory: () => localStorage,
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -138,11 +138,11 @@ export const LOCAL_STORAGE = new InjectionToken<Storage>("LocalStorage", {
|
|||||||
// app.config.ts
|
// app.config.ts
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: API_URL, useValue: "https://api.example.com" },
|
{ provide: API_URL, useValue: 'https://api.example.com' },
|
||||||
{
|
{
|
||||||
provide: APP_CONFIG,
|
provide: APP_CONFIG,
|
||||||
useValue: {
|
useValue: {
|
||||||
apiUrl: "https://api.example.com",
|
apiUrl: 'https://api.example.com',
|
||||||
features: { darkMode: true, analytics: true },
|
features: { darkMode: true, analytics: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -153,7 +153,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
### Injecting Tokens
|
### Injecting Tokens
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class Api {
|
export class Api {
|
||||||
private apiUrl = inject(API_URL);
|
private apiUrl = inject(API_URL);
|
||||||
private config = inject(APP_CONFIG);
|
private config = inject(APP_CONFIG);
|
||||||
@@ -268,7 +268,7 @@ Collect multiple values for same token:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Token for multiple validators
|
// Token for multiple validators
|
||||||
export const VALIDATORS = new InjectionToken<Validator[]>("Validators");
|
export const VALIDATORS = new InjectionToken<Validator[]>('Validators');
|
||||||
|
|
||||||
// Provide multiple values
|
// Provide multiple values
|
||||||
providers: [
|
providers: [
|
||||||
@@ -293,11 +293,7 @@ export class Validation {
|
|||||||
```typescript
|
```typescript
|
||||||
// Interceptors use multi providers internally
|
// Interceptors use multi providers internally
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [provideHttpClient(withInterceptors([authInterceptor, loggingInterceptor, errorInterceptor]))],
|
||||||
provideHttpClient(
|
|
||||||
withInterceptors([authInterceptor, loggingInterceptor, errorInterceptor]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -306,7 +302,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
Run async code before app starts using `provideAppInitializer`:
|
Run async code before app starts using `provideAppInitializer`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { provideAppInitializer, inject } from "@angular/core";
|
import { provideAppInitializer, inject } from '@angular/core';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -339,13 +335,9 @@ providers: [
|
|||||||
Create injectors programmatically:
|
Create injectors programmatically:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import { createEnvironmentInjector, EnvironmentInjector, inject } from '@angular/core';
|
||||||
createEnvironmentInjector,
|
|
||||||
EnvironmentInjector,
|
|
||||||
inject,
|
|
||||||
} from "@angular/core";
|
|
||||||
|
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class Plugin {
|
export class Plugin {
|
||||||
private parentInjector = inject(EnvironmentInjector);
|
private parentInjector = inject(EnvironmentInjector);
|
||||||
|
|
||||||
@@ -360,13 +352,9 @@ export class Plugin {
|
|||||||
Run code with injection context:
|
Run code with injection context:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import { runInInjectionContext, EnvironmentInjector, inject } from '@angular/core';
|
||||||
runInInjectionContext,
|
|
||||||
EnvironmentInjector,
|
|
||||||
inject,
|
|
||||||
} from "@angular/core";
|
|
||||||
|
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class Utility {
|
export class Utility {
|
||||||
private injector = inject(EnvironmentInjector);
|
private injector = inject(EnvironmentInjector);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
Combine multiple services into a single API:
|
Combine multiple services into a single API:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ShopFacade {
|
export class ShopFacade {
|
||||||
private productService = inject(Product);
|
private productService = inject(Product);
|
||||||
private cartService = inject(Cart);
|
private cartService = inject(Cart);
|
||||||
@@ -53,7 +53,7 @@ interface UserState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class UserState {
|
export class UserState {
|
||||||
private state = signal<UserState>({
|
private state = signal<UserState>({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -208,7 +208,7 @@ export class User {
|
|||||||
```typescript
|
```typescript
|
||||||
// Parent provides service
|
// Parent provides service
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-form-container",
|
selector: 'app-form-container',
|
||||||
providers: [FormState],
|
providers: [FormState],
|
||||||
template: `
|
template: `
|
||||||
<app-form-header />
|
<app-form-header />
|
||||||
@@ -222,7 +222,7 @@ export class FormContainer {
|
|||||||
|
|
||||||
// Children share same instance
|
// Children share same instance
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-form-body",
|
selector: 'app-form-body',
|
||||||
template: `...`,
|
template: `...`,
|
||||||
})
|
})
|
||||||
export class FormBody {
|
export class FormBody {
|
||||||
@@ -232,7 +232,7 @@ export class FormBody {
|
|||||||
|
|
||||||
// Grandchildren also share
|
// Grandchildren also share
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-form-field",
|
selector: 'app-form-field',
|
||||||
template: `...`,
|
template: `...`,
|
||||||
})
|
})
|
||||||
export class FormField {
|
export class FormField {
|
||||||
@@ -245,7 +245,7 @@ export class FormField {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-tabs",
|
selector: 'app-tabs',
|
||||||
// providers: Available to component AND content children
|
// providers: Available to component AND content children
|
||||||
providers: [TabsSvc],
|
providers: [TabsSvc],
|
||||||
|
|
||||||
@@ -338,12 +338,12 @@ import { PLATFORM_ID, isPlatformBrowser } from '@angular/common';
|
|||||||
### Mocking Services
|
### Mocking Services
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
describe("UserCmpt", () => {
|
describe('UserCmpt', () => {
|
||||||
let userServiceSpy: jasmine.SpyObj<User>;
|
let userServiceSpy: jasmine.SpyObj<User>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
userServiceSpy = jasmine.createSpyObj("User", ["getUser", "updateUser"]);
|
userServiceSpy = jasmine.createSpyObj('User', ['getUser', 'updateUser']);
|
||||||
userServiceSpy.getUser.and.returnValue(of({ id: "1", name: "Test" }));
|
userServiceSpy.getUser.and.returnValue(of({ id: '1', name: 'Test' }));
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [UserCmpt],
|
imports: [UserCmpt],
|
||||||
@@ -351,7 +351,7 @@ describe("UserCmpt", () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should load user", () => {
|
it('should load user', () => {
|
||||||
const fixture = TestBed.createComponent(UserCmpt);
|
const fixture = TestBed.createComponent(UserCmpt);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -363,13 +363,13 @@ describe("UserCmpt", () => {
|
|||||||
### Overriding Providers
|
### Overriding Providers
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
describe("with different config", () => {
|
describe('with different config', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
})
|
})
|
||||||
.overrideProvider(APP_CONFIG, {
|
.overrideProvider(APP_CONFIG, {
|
||||||
useValue: { apiUrl: "http://test-api.com" },
|
useValue: { apiUrl: 'http://test-api.com' },
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
});
|
});
|
||||||
@@ -379,14 +379,14 @@ describe("with different config", () => {
|
|||||||
### Testing Injection Tokens
|
### Testing Injection Tokens
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
describe("API_URL token", () => {
|
describe('API_URL token', () => {
|
||||||
it("should provide correct URL", () => {
|
it('should provide correct URL', () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [{ provide: API_URL, useValue: "https://api.test.com" }],
|
providers: [{ provide: API_URL, useValue: 'https://api.test.com' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiUrl = TestBed.inject(API_URL);
|
const apiUrl = TestBed.inject(API_URL);
|
||||||
expect(apiUrl).toBe("https://api.test.com");
|
expect(apiUrl).toBe('https://api.test.com');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ Create custom directives for reusable DOM manipulation and behavior in Angular v
|
|||||||
Modify the appearance or behavior of an element:
|
Modify the appearance or behavior of an element:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Directive, input, effect, inject, ElementRef } from "@angular/core";
|
import { Directive, input, effect, inject, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appHighlight]",
|
selector: '[appHighlight]',
|
||||||
})
|
})
|
||||||
export class Highlight {
|
export class Highlight {
|
||||||
private el = inject(ElementRef<HTMLElement>);
|
private el = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
// Input with alias matching selector
|
// Input with alias matching selector
|
||||||
color = input("yellow", { alias: "appHighlight" });
|
color = input('yellow', { alias: 'appHighlight' });
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -40,27 +40,27 @@ Prefer `host` over `@HostBinding`/`@HostListener`:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appTooltip]",
|
selector: '[appTooltip]',
|
||||||
host: {
|
host: {
|
||||||
"(mouseenter)": "show()",
|
'(mouseenter)': 'show()',
|
||||||
"(mouseleave)": "hide()",
|
'(mouseleave)': 'hide()',
|
||||||
"[attr.aria-describedby]": "tooltipId",
|
'[attr.aria-describedby]': 'tooltipId',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Tooltip {
|
export class Tooltip {
|
||||||
text = input.required<string>({ alias: "appTooltip" });
|
text = input.required<string>({ alias: 'appTooltip' });
|
||||||
position = input<"top" | "bottom" | "left" | "right">("top");
|
position = input<'top' | 'bottom' | 'left' | 'right'>('top');
|
||||||
|
|
||||||
tooltipId = `tooltip-${crypto.randomUUID()}`;
|
tooltipId = `tooltip-${crypto.randomUUID()}`;
|
||||||
private tooltipEl: HTMLElement | null = null;
|
private tooltipEl: HTMLElement | null = null;
|
||||||
private el = inject(ElementRef<HTMLElement>);
|
private el = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
show() {
|
show() {
|
||||||
this.tooltipEl = document.createElement("div");
|
this.tooltipEl = document.createElement('div');
|
||||||
this.tooltipEl.id = this.tooltipId;
|
this.tooltipEl.id = this.tooltipId;
|
||||||
this.tooltipEl.className = `tooltip tooltip-${this.position()}`;
|
this.tooltipEl.className = `tooltip tooltip-${this.position()}`;
|
||||||
this.tooltipEl.textContent = this.text();
|
this.tooltipEl.textContent = this.text();
|
||||||
this.tooltipEl.setAttribute("role", "tooltip");
|
this.tooltipEl.setAttribute('role', 'tooltip');
|
||||||
document.body.appendChild(this.tooltipEl);
|
document.body.appendChild(this.tooltipEl);
|
||||||
this.positionTooltip();
|
this.positionTooltip();
|
||||||
}
|
}
|
||||||
@@ -82,20 +82,20 @@ export class Tooltip {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appButton]",
|
selector: '[appButton]',
|
||||||
host: {
|
host: {
|
||||||
class: "btn",
|
'class': 'btn',
|
||||||
"[class.btn-primary]": 'variant() === "primary"',
|
'[class.btn-primary]': 'variant() === "primary"',
|
||||||
"[class.btn-secondary]": 'variant() === "secondary"',
|
'[class.btn-secondary]': 'variant() === "secondary"',
|
||||||
"[class.btn-sm]": 'size() === "small"',
|
'[class.btn-sm]': 'size() === "small"',
|
||||||
"[class.btn-lg]": 'size() === "large"',
|
'[class.btn-lg]': 'size() === "large"',
|
||||||
"[class.disabled]": "disabled()",
|
'[class.disabled]': 'disabled()',
|
||||||
"[attr.disabled]": "disabled() || null",
|
'[attr.disabled]': 'disabled() || null',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Button {
|
export class Button {
|
||||||
variant = input<"primary" | "secondary">("primary");
|
variant = input<'primary' | 'secondary'>('primary');
|
||||||
size = input<"small" | "medium" | "large">("medium");
|
size = input<'small' | 'medium' | 'large'>('medium');
|
||||||
disabled = input(false, { transform: booleanAttribute });
|
disabled = input(false, { transform: booleanAttribute });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +106,9 @@ export class Button {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appClickOutside]",
|
selector: '[appClickOutside]',
|
||||||
host: {
|
host: {
|
||||||
"(document:click)": "onDocumentClick($event)",
|
'(document:click)': 'onDocumentClick($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class ClickOutside {
|
export class ClickOutside {
|
||||||
@@ -130,13 +130,13 @@ export class ClickOutside {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appShortcut]",
|
selector: '[appShortcut]',
|
||||||
host: {
|
host: {
|
||||||
"(document:keydown)": "onKeydown($event)",
|
'(document:keydown)': 'onKeydown($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Shortcut {
|
export class Shortcut {
|
||||||
key = input.required<string>({ alias: "appShortcut" });
|
key = input.required<string>({ alias: 'appShortcut' });
|
||||||
ctrl = input(false, { transform: booleanAttribute });
|
ctrl = input(false, { transform: booleanAttribute });
|
||||||
shift = input(false, { transform: booleanAttribute });
|
shift = input(false, { transform: booleanAttribute });
|
||||||
alt = input(false, { transform: booleanAttribute });
|
alt = input(false, { transform: booleanAttribute });
|
||||||
@@ -145,9 +145,7 @@ export class Shortcut {
|
|||||||
|
|
||||||
onKeydown(event: KeyboardEvent) {
|
onKeydown(event: KeyboardEvent) {
|
||||||
const keyMatch = event.key.toLowerCase() === this.key().toLowerCase();
|
const keyMatch = event.key.toLowerCase() === this.key().toLowerCase();
|
||||||
const ctrlMatch = this.ctrl()
|
const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
|
||||||
? event.ctrlKey || event.metaKey
|
|
||||||
: !event.ctrlKey && !event.metaKey;
|
|
||||||
const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey;
|
const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey;
|
||||||
const altMatch = this.alt() ? event.altKey : !event.altKey;
|
const altMatch = this.alt() ? event.altKey : !event.altKey;
|
||||||
|
|
||||||
@@ -170,18 +168,10 @@ Use structural directives for DOM manipulation beyond control flow (portals, ove
|
|||||||
Render content in a different DOM location:
|
Render content in a different DOM location:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core';
|
||||||
Directive,
|
|
||||||
inject,
|
|
||||||
TemplateRef,
|
|
||||||
ViewContainerRef,
|
|
||||||
OnInit,
|
|
||||||
OnDestroy,
|
|
||||||
input,
|
|
||||||
} from "@angular/core";
|
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appPortal]",
|
selector: '[appPortal]',
|
||||||
})
|
})
|
||||||
export class Portal implements OnInit, OnDestroy {
|
export class Portal implements OnInit, OnDestroy {
|
||||||
private templateRef = inject(TemplateRef<any>);
|
private templateRef = inject(TemplateRef<any>);
|
||||||
@@ -189,7 +179,7 @@ export class Portal implements OnInit, OnDestroy {
|
|||||||
private viewRef: EmbeddedViewRef<any> | null = null;
|
private viewRef: EmbeddedViewRef<any> | null = null;
|
||||||
|
|
||||||
// Target container selector or element
|
// Target container selector or element
|
||||||
target = input<string | HTMLElement>("body", { alias: "appPortal" });
|
target = input<string | HTMLElement>('body', { alias: 'appPortal' });
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const container = this.getContainer();
|
const container = this.getContainer();
|
||||||
@@ -205,7 +195,7 @@ export class Portal implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private getContainer(): HTMLElement | null {
|
private getContainer(): HTMLElement | null {
|
||||||
const target = this.target();
|
const target = this.target();
|
||||||
if (typeof target === "string") {
|
if (typeof target === 'string') {
|
||||||
return document.querySelector(target);
|
return document.querySelector(target);
|
||||||
}
|
}
|
||||||
return target;
|
return target;
|
||||||
@@ -224,14 +214,14 @@ Defer rendering until condition is met (one-time):
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appLazyRender]",
|
selector: '[appLazyRender]',
|
||||||
})
|
})
|
||||||
export class LazyRender {
|
export class LazyRender {
|
||||||
private templateRef = inject(TemplateRef<any>);
|
private templateRef = inject(TemplateRef<any>);
|
||||||
private viewContainer = inject(ViewContainerRef);
|
private viewContainer = inject(ViewContainerRef);
|
||||||
private rendered = false;
|
private rendered = false;
|
||||||
|
|
||||||
condition = input.required<boolean>({ alias: "appLazyRender" });
|
condition = input.required<boolean>({ alias: 'appLazyRender' });
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -260,17 +250,17 @@ interface TemplateContext<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appTemplateOutlet]",
|
selector: '[appTemplateOutlet]',
|
||||||
})
|
})
|
||||||
export class TemplateOutlet<T> {
|
export class TemplateOutlet<T> {
|
||||||
private viewContainer = inject(ViewContainerRef);
|
private viewContainer = inject(ViewContainerRef);
|
||||||
private currentView: EmbeddedViewRef<TemplateContext<T>> | null = null;
|
private currentView: EmbeddedViewRef<TemplateContext<T>> | null = null;
|
||||||
|
|
||||||
template = input.required<TemplateRef<TemplateContext<T>>>({
|
template = input.required<TemplateRef<TemplateContext<T>>>({
|
||||||
alias: "appTemplateOutlet",
|
alias: 'appTemplateOutlet',
|
||||||
});
|
});
|
||||||
context = input.required<T>({ alias: "appTemplateOutletContext" });
|
context = input.required<T>({ alias: 'appTemplateOutletContext' });
|
||||||
index = input(0, { alias: "appTemplateOutletIndex" });
|
index = input(0, { alias: 'appTemplateOutletIndex' });
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -310,12 +300,12 @@ Compose directives on components or other directives:
|
|||||||
```typescript
|
```typescript
|
||||||
// Reusable behavior directives
|
// Reusable behavior directives
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[focusable]",
|
selector: '[focusable]',
|
||||||
host: {
|
host: {
|
||||||
tabindex: "0",
|
'tabindex': '0',
|
||||||
"(focus)": "onFocus()",
|
'(focus)': 'onFocus()',
|
||||||
"(blur)": "onBlur()",
|
'(blur)': 'onBlur()',
|
||||||
"[class.focused]": "isFocused()",
|
'[class.focused]': 'isFocused()',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Focusable {
|
export class Focusable {
|
||||||
@@ -330,10 +320,10 @@ export class Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[disableable]",
|
selector: '[disableable]',
|
||||||
host: {
|
host: {
|
||||||
"[class.disabled]": "disabled()",
|
'[class.disabled]': 'disabled()',
|
||||||
"[attr.aria-disabled]": "disabled()",
|
'[attr.aria-disabled]': 'disabled()',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Disableable {
|
export class Disableable {
|
||||||
@@ -342,19 +332,19 @@ export class Disableable {
|
|||||||
|
|
||||||
// Component using host directives
|
// Component using host directives
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-custom-button",
|
selector: 'app-custom-button',
|
||||||
hostDirectives: [
|
hostDirectives: [
|
||||||
Focusable,
|
Focusable,
|
||||||
{
|
{
|
||||||
directive: Disableable,
|
directive: Disableable,
|
||||||
inputs: ["disabled"],
|
inputs: ['disabled'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
host: {
|
host: {
|
||||||
role: "button",
|
'role': 'button',
|
||||||
"(click)": "onClick($event)",
|
'(click)': 'onClick($event)',
|
||||||
"(keydown.enter)": "onClick($event)",
|
'(keydown.enter)': 'onClick($event)',
|
||||||
"(keydown.space)": "onClick($event)",
|
'(keydown.space)': 'onClick($event)',
|
||||||
},
|
},
|
||||||
template: `<ng-content />`,
|
template: `<ng-content />`,
|
||||||
})
|
})
|
||||||
@@ -377,11 +367,11 @@ export class CustomButton {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[hoverable]",
|
selector: '[hoverable]',
|
||||||
host: {
|
host: {
|
||||||
"(mouseenter)": "onEnter()",
|
'(mouseenter)': 'onEnter()',
|
||||||
"(mouseleave)": "onLeave()",
|
'(mouseleave)': 'onLeave()',
|
||||||
"[class.hovered]": "isHovered()",
|
'[class.hovered]': 'isHovered()',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Hoverable {
|
export class Hoverable {
|
||||||
@@ -401,11 +391,11 @@ export class Hoverable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-card",
|
selector: 'app-card',
|
||||||
hostDirectives: [
|
hostDirectives: [
|
||||||
{
|
{
|
||||||
directive: Hoverable,
|
directive: Hoverable,
|
||||||
outputs: ["hoverChange"],
|
outputs: ['hoverChange'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
template: `<ng-content />`,
|
template: `<ng-content />`,
|
||||||
@@ -421,28 +411,28 @@ Combine multiple behaviors:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Base directives
|
// Base directives
|
||||||
@Directive({ selector: "[withRipple]" })
|
@Directive({ selector: '[withRipple]' })
|
||||||
export class Ripple {
|
export class Ripple {
|
||||||
// Ripple effect implementation
|
// Ripple effect implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
@Directive({ selector: "[withElevation]" })
|
@Directive({ selector: '[withElevation]' })
|
||||||
export class Elevation {
|
export class Elevation {
|
||||||
elevation = input(2);
|
elevation = input(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Composed component
|
// Composed component
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-material-button",
|
selector: 'app-material-button',
|
||||||
hostDirectives: [
|
hostDirectives: [
|
||||||
Ripple,
|
Ripple,
|
||||||
{
|
{
|
||||||
directive: Elevation,
|
directive: Elevation,
|
||||||
inputs: ["elevation"],
|
inputs: ['elevation'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
directive: Disableable,
|
directive: Disableable,
|
||||||
inputs: ["disabled"],
|
inputs: ['disabled'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
template: `<ng-content />`,
|
template: `<ng-content />`,
|
||||||
|
|||||||
@@ -15,12 +15,12 @@
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appAutoFocus]",
|
selector: '[appAutoFocus]',
|
||||||
})
|
})
|
||||||
export class AutoFocus {
|
export class AutoFocus {
|
||||||
private el = inject(ElementRef<HTMLElement>);
|
private el = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
enabled = input(true, { alias: "appAutoFocus", transform: booleanAttribute });
|
enabled = input(true, { alias: 'appAutoFocus', transform: booleanAttribute });
|
||||||
delay = input(0);
|
delay = input(0);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -42,10 +42,10 @@ export class AutoFocus {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appSelectAll]",
|
selector: '[appSelectAll]',
|
||||||
host: {
|
host: {
|
||||||
"(focus)": "onFocus()",
|
'(focus)': 'onFocus()',
|
||||||
"(click)": "onClick($event)",
|
'(click)': 'onClick($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class SelectAll {
|
export class SelectAll {
|
||||||
@@ -71,14 +71,14 @@ export class SelectAll {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appCopyToClipboard]",
|
selector: '[appCopyToClipboard]',
|
||||||
host: {
|
host: {
|
||||||
"(click)": "copy()",
|
'(click)': 'copy()',
|
||||||
"[style.cursor]": '"pointer"',
|
'[style.cursor]': '"pointer"',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class CopyToClipboard {
|
export class CopyToClipboard {
|
||||||
text = input.required<string>({ alias: "appCopyToClipboard" });
|
text = input.required<string>({ alias: 'appCopyToClipboard' });
|
||||||
|
|
||||||
copied = output<void>();
|
copied = output<void>();
|
||||||
error = output<Error>();
|
error = output<Error>();
|
||||||
@@ -105,9 +105,9 @@ export class CopyToClipboard {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "input[appTrim], textarea[appTrim]",
|
selector: 'input[appTrim], textarea[appTrim]',
|
||||||
host: {
|
host: {
|
||||||
"(blur)": "onBlur()",
|
'(blur)': 'onBlur()',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Trim {
|
export class Trim {
|
||||||
@@ -132,17 +132,17 @@ export class Trim {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appMask]",
|
selector: '[appMask]',
|
||||||
host: {
|
host: {
|
||||||
"(input)": "onInput($event)",
|
'(input)': 'onInput($event)',
|
||||||
"(keydown)": "onKeydown($event)",
|
'(keydown)': 'onKeydown($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Mask {
|
export class Mask {
|
||||||
private el = inject(ElementRef<HTMLInputElement>);
|
private el = inject(ElementRef<HTMLInputElement>);
|
||||||
|
|
||||||
// Mask pattern: 9 = digit, A = letter, * = any
|
// Mask pattern: 9 = digit, A = letter, * = any
|
||||||
mask = input.required<string>({ alias: "appMask" });
|
mask = input.required<string>({ alias: 'appMask' });
|
||||||
|
|
||||||
onInput(event: InputEvent) {
|
onInput(event: InputEvent) {
|
||||||
const input = this.el.nativeElement;
|
const input = this.el.nativeElement;
|
||||||
@@ -156,11 +156,7 @@ export class Mask {
|
|||||||
|
|
||||||
onKeydown(event: KeyboardEvent) {
|
onKeydown(event: KeyboardEvent) {
|
||||||
// Allow navigation keys
|
// Allow navigation keys
|
||||||
if (
|
if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(event.key)) {
|
||||||
["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"].includes(
|
|
||||||
event.key,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,14 +176,14 @@ export class Mask {
|
|||||||
|
|
||||||
private applyMask(value: string): string {
|
private applyMask(value: string): string {
|
||||||
const mask = this.mask();
|
const mask = this.mask();
|
||||||
let result = "";
|
let result = '';
|
||||||
let valueIndex = 0;
|
let valueIndex = 0;
|
||||||
|
|
||||||
for (let i = 0; i < mask.length && valueIndex < value.length; i++) {
|
for (let i = 0; i < mask.length && valueIndex < value.length; i++) {
|
||||||
const maskChar = mask[i];
|
const maskChar = mask[i];
|
||||||
const inputChar = value[valueIndex];
|
const inputChar = value[valueIndex];
|
||||||
|
|
||||||
if (maskChar === "9" || maskChar === "A" || maskChar === "*") {
|
if (maskChar === '9' || maskChar === 'A' || maskChar === '*') {
|
||||||
if (this.isValidChar(inputChar, maskChar)) {
|
if (this.isValidChar(inputChar, maskChar)) {
|
||||||
result += inputChar;
|
result += inputChar;
|
||||||
valueIndex++;
|
valueIndex++;
|
||||||
@@ -208,11 +204,11 @@ export class Mask {
|
|||||||
|
|
||||||
private isValidChar(char: string, maskChar: string): boolean {
|
private isValidChar(char: string, maskChar: string): boolean {
|
||||||
switch (maskChar) {
|
switch (maskChar) {
|
||||||
case "9":
|
case '9':
|
||||||
return /\d/.test(char);
|
return /\d/.test(char);
|
||||||
case "A":
|
case 'A':
|
||||||
return /[a-zA-Z]/.test(char);
|
return /[a-zA-Z]/.test(char);
|
||||||
case "*":
|
case '*':
|
||||||
return /[a-zA-Z0-9]/.test(char);
|
return /[a-zA-Z0-9]/.test(char);
|
||||||
default:
|
default:
|
||||||
return char === maskChar;
|
return char === maskChar;
|
||||||
@@ -227,12 +223,12 @@ export class Mask {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appCharCount]",
|
selector: '[appCharCount]',
|
||||||
})
|
})
|
||||||
export class CharCount {
|
export class CharCount {
|
||||||
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
|
private el = inject(ElementRef<HTMLInputElement | HTMLTextAreaElement>);
|
||||||
|
|
||||||
maxLength = input.required<number>({ alias: "appCharCount" });
|
maxLength = input.required<number>({ alias: 'appCharCount' });
|
||||||
|
|
||||||
currentLength = signal(0);
|
currentLength = signal(0);
|
||||||
remaining = computed(() => this.maxLength() - this.currentLength());
|
remaining = computed(() => this.maxLength() - this.currentLength());
|
||||||
@@ -245,7 +241,7 @@ export class CharCount {
|
|||||||
|
|
||||||
// Listen for input changes
|
// Listen for input changes
|
||||||
afterNextRender(() => {
|
afterNextRender(() => {
|
||||||
this.el.nativeElement.addEventListener("input", () => {
|
this.el.nativeElement.addEventListener('input', () => {
|
||||||
this.currentLength.set(this.el.nativeElement.value.length);
|
this.currentLength.set(this.el.nativeElement.value.length);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -263,14 +259,14 @@ export class CharCount {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appLazyLoad]",
|
selector: '[appLazyLoad]',
|
||||||
})
|
})
|
||||||
export class LazyLoad implements OnDestroy {
|
export class LazyLoad implements OnDestroy {
|
||||||
private el = inject(ElementRef<HTMLElement>);
|
private el = inject(ElementRef<HTMLElement>);
|
||||||
private observer: IntersectionObserver | null = null;
|
private observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
src = input.required<string>({ alias: "appLazyLoad" });
|
src = input.required<string>({ alias: 'appLazyLoad' });
|
||||||
placeholder = input("/assets/placeholder.png");
|
placeholder = input('/assets/placeholder.png');
|
||||||
|
|
||||||
loaded = output<void>();
|
loaded = output<void>();
|
||||||
|
|
||||||
@@ -290,7 +286,7 @@ export class LazyLoad implements OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ rootMargin: "50px" },
|
{ rootMargin: '50px' },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.observer.observe(this.el.nativeElement);
|
this.observer.observe(this.el.nativeElement);
|
||||||
@@ -325,7 +321,7 @@ export class LazyLoad implements OnDestroy {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appInfiniteScroll]",
|
selector: '[appInfiniteScroll]',
|
||||||
})
|
})
|
||||||
export class InfiniteScroll implements OnDestroy {
|
export class InfiniteScroll implements OnDestroy {
|
||||||
private el = inject(ElementRef<HTMLElement>);
|
private el = inject(ElementRef<HTMLElement>);
|
||||||
@@ -385,7 +381,7 @@ export class InfiniteScroll implements OnDestroy {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appResize]",
|
selector: '[appResize]',
|
||||||
})
|
})
|
||||||
export class Resize implements OnDestroy {
|
export class Resize implements OnDestroy {
|
||||||
private el = inject(ElementRef<HTMLElement>);
|
private el = inject(ElementRef<HTMLElement>);
|
||||||
@@ -426,17 +422,17 @@ export class Resize implements OnDestroy {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appDraggable]",
|
selector: '[appDraggable]',
|
||||||
host: {
|
host: {
|
||||||
draggable: "true",
|
'draggable': 'true',
|
||||||
"[class.dragging]": "isDragging()",
|
'[class.dragging]': 'isDragging()',
|
||||||
"(dragstart)": "onDragStart($event)",
|
'(dragstart)': 'onDragStart($event)',
|
||||||
"(dragend)": "onDragEnd($event)",
|
'(dragend)': 'onDragEnd($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Draggable {
|
export class Draggable {
|
||||||
data = input<any>(null, { alias: "appDraggable" });
|
data = input<any>(null, { alias: 'appDraggable' });
|
||||||
effectAllowed = input<DataTransfer["effectAllowed"]>("move");
|
effectAllowed = input<DataTransfer['effectAllowed']>('move');
|
||||||
|
|
||||||
isDragging = signal(false);
|
isDragging = signal(false);
|
||||||
|
|
||||||
@@ -448,10 +444,7 @@ export class Draggable {
|
|||||||
|
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.effectAllowed = this.effectAllowed();
|
event.dataTransfer.effectAllowed = this.effectAllowed();
|
||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData('application/json', JSON.stringify(this.data()));
|
||||||
"application/json",
|
|
||||||
JSON.stringify(this.data()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dragStart.emit(event);
|
this.dragStart.emit(event);
|
||||||
@@ -464,12 +457,12 @@ export class Draggable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appDropZone]",
|
selector: '[appDropZone]',
|
||||||
host: {
|
host: {
|
||||||
"[class.drag-over]": "isDragOver()",
|
'[class.drag-over]': 'isDragOver()',
|
||||||
"(dragover)": "onDragOver($event)",
|
'(dragover)': 'onDragOver($event)',
|
||||||
"(dragleave)": "onDragLeave($event)",
|
'(dragleave)': 'onDragLeave($event)',
|
||||||
"(drop)": "onDrop($event)",
|
'(drop)': 'onDrop($event)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class DropZone {
|
export class DropZone {
|
||||||
@@ -490,7 +483,7 @@ export class DropZone {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.isDragOver.set(false);
|
this.isDragOver.set(false);
|
||||||
|
|
||||||
const data = event.dataTransfer?.getData("application/json");
|
const data = event.dataTransfer?.getData('application/json');
|
||||||
if (data) {
|
if (data) {
|
||||||
this.dropped.emit(JSON.parse(data));
|
this.dropped.emit(JSON.parse(data));
|
||||||
}
|
}
|
||||||
@@ -506,7 +499,7 @@ export class DropZone {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appHasPermission]",
|
selector: '[appHasPermission]',
|
||||||
})
|
})
|
||||||
export class HasPermission {
|
export class HasPermission {
|
||||||
private templateRef = inject(TemplateRef<any>);
|
private templateRef = inject(TemplateRef<any>);
|
||||||
@@ -514,8 +507,8 @@ export class HasPermission {
|
|||||||
private authService = inject(Auth);
|
private authService = inject(Auth);
|
||||||
private hasView = false;
|
private hasView = false;
|
||||||
|
|
||||||
permission = input.required<string | string[]>({ alias: "appHasPermission" });
|
permission = input.required<string | string[]>({ alias: 'appHasPermission' });
|
||||||
mode = input<"any" | "all">("any");
|
mode = input<'any' | 'all'>('any');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -536,7 +529,7 @@ export class HasPermission {
|
|||||||
const permissions = Array.isArray(required) ? required : [required];
|
const permissions = Array.isArray(required) ? required : [required];
|
||||||
const userPermissions = this.authService.permissions();
|
const userPermissions = this.authService.permissions();
|
||||||
|
|
||||||
if (this.mode() === "all") {
|
if (this.mode() === 'all') {
|
||||||
return permissions.every((p) => userPermissions.includes(p));
|
return permissions.every((p) => userPermissions.includes(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,8 +546,8 @@ export class HasPermission {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appToggle]",
|
selector: '[appToggle]',
|
||||||
exportAs: "appToggle",
|
exportAs: 'appToggle',
|
||||||
})
|
})
|
||||||
export class Toggle {
|
export class Toggle {
|
||||||
isOpen = signal(false);
|
isOpen = signal(false);
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms p
|
|||||||
## Basic Setup
|
## Basic Setup
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Component, signal } from "@angular/core";
|
import { Component, signal } from '@angular/core';
|
||||||
import { form, FormField, required, email } from "@angular/forms/signals";
|
import { form, FormField, required, email } from '@angular/forms/signals';
|
||||||
|
|
||||||
interface LoginData {
|
interface LoginData {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -21,13 +21,15 @@ interface LoginData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-login",
|
selector: 'app-login',
|
||||||
imports: [FormField],
|
imports: [FormField],
|
||||||
template: `
|
template: `
|
||||||
<form (submit)="onSubmit($event)">
|
<form (submit)="onSubmit($event)">
|
||||||
<label>
|
<label>
|
||||||
Email
|
Email
|
||||||
<input type="email" [formField]="loginForm.email" />
|
<input
|
||||||
|
type="email"
|
||||||
|
[formField]="loginForm.email" />
|
||||||
</label>
|
</label>
|
||||||
@if (loginForm.email().touched() && loginForm.email().invalid()) {
|
@if (loginForm.email().touched() && loginForm.email().invalid()) {
|
||||||
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
|
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
|
||||||
@@ -35,35 +37,41 @@ interface LoginData {
|
|||||||
|
|
||||||
<label>
|
<label>
|
||||||
Password
|
Password
|
||||||
<input type="password" [formField]="loginForm.password" />
|
<input
|
||||||
|
type="password"
|
||||||
|
[formField]="loginForm.password" />
|
||||||
</label>
|
</label>
|
||||||
@if (loginForm.password().touched() && loginForm.password().invalid()) {
|
@if (loginForm.password().touched() && loginForm.password().invalid()) {
|
||||||
<p class="error">{{ loginForm.password().errors()[0].message }}</p>
|
<p class="error">{{ loginForm.password().errors()[0].message }}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button type="submit" [disabled]="loginForm().invalid()">Login</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="loginForm().invalid()">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class Login {
|
export class Login {
|
||||||
// Form model - a writable signal
|
// Form model - a writable signal
|
||||||
loginModel = signal<LoginData>({
|
loginModel = signal<LoginData>({
|
||||||
email: "",
|
email: '',
|
||||||
password: "",
|
password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create form with validation schema
|
// Create form with validation schema
|
||||||
loginForm = form(this.loginModel, (schemaPath) => {
|
loginForm = form(this.loginModel, (schemaPath) => {
|
||||||
required(schemaPath.email, { message: "Email is required" });
|
required(schemaPath.email, { message: 'Email is required' });
|
||||||
email(schemaPath.email, { message: "Enter a valid email address" });
|
email(schemaPath.email, { message: 'Enter a valid email address' });
|
||||||
required(schemaPath.password, { message: "Password is required" });
|
required(schemaPath.password, { message: 'Password is required' });
|
||||||
});
|
});
|
||||||
|
|
||||||
onSubmit(event: Event) {
|
onSubmit(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.loginForm().valid()) {
|
if (this.loginForm().valid()) {
|
||||||
const credentials = this.loginModel();
|
const credentials = this.loginModel();
|
||||||
console.log("Submitting:", credentials);
|
console.log('Submitting:', credentials);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,18 +89,18 @@ interface UserProfile {
|
|||||||
age: number | null;
|
age: number | null;
|
||||||
preferences: {
|
preferences: {
|
||||||
newsletter: boolean;
|
newsletter: boolean;
|
||||||
theme: "light" | "dark";
|
theme: 'light' | 'dark';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create model signal with initial values
|
// Create model signal with initial values
|
||||||
const userModel = signal<UserProfile>({
|
const userModel = signal<UserProfile>({
|
||||||
name: "",
|
name: '',
|
||||||
email: "",
|
email: '',
|
||||||
age: null,
|
age: null,
|
||||||
preferences: {
|
preferences: {
|
||||||
newsletter: false,
|
newsletter: false,
|
||||||
theme: "light",
|
theme: 'light',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,14 +128,14 @@ const theme = this.userForm.preferences.theme().value();
|
|||||||
```typescript
|
```typescript
|
||||||
// Replace entire model
|
// Replace entire model
|
||||||
this.userModel.set({
|
this.userModel.set({
|
||||||
name: "Alice",
|
name: 'Alice',
|
||||||
email: "alice@example.com",
|
email: 'alice@example.com',
|
||||||
age: 30,
|
age: 30,
|
||||||
preferences: { newsletter: true, theme: "dark" },
|
preferences: { newsletter: true, theme: 'dark' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update single field
|
// Update single field
|
||||||
this.userForm.name().value.set("Bob");
|
this.userForm.name().value.set('Bob');
|
||||||
this.userForm.age().value.update((age) => (age ?? 0) + 1);
|
this.userForm.age().value.update((age) => (age ?? 0) + 1);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -177,35 +185,26 @@ this.form().dirty();
|
|||||||
### Built-in Validators
|
### Built-in Validators
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import { form, required, email, min, max, minLength, maxLength, pattern } from '@angular/forms/signals';
|
||||||
form,
|
|
||||||
required,
|
|
||||||
email,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
minLength,
|
|
||||||
maxLength,
|
|
||||||
pattern,
|
|
||||||
} from "@angular/forms/signals";
|
|
||||||
|
|
||||||
const userForm = form(this.userModel, (schemaPath) => {
|
const userForm = form(this.userModel, (schemaPath) => {
|
||||||
// Required field
|
// Required field
|
||||||
required(schemaPath.name, { message: "Name is required" });
|
required(schemaPath.name, { message: 'Name is required' });
|
||||||
|
|
||||||
// Email format
|
// Email format
|
||||||
email(schemaPath.email, { message: "Invalid email" });
|
email(schemaPath.email, { message: 'Invalid email' });
|
||||||
|
|
||||||
// Numeric range
|
// Numeric range
|
||||||
min(schemaPath.age, 18, { message: "Must be 18+" });
|
min(schemaPath.age, 18, { message: 'Must be 18+' });
|
||||||
max(schemaPath.age, 120, { message: "Invalid age" });
|
max(schemaPath.age, 120, { message: 'Invalid age' });
|
||||||
|
|
||||||
// String/array length
|
// String/array length
|
||||||
minLength(schemaPath.password, 8, { message: "Min 8 characters" });
|
minLength(schemaPath.password, 8, { message: 'Min 8 characters' });
|
||||||
maxLength(schemaPath.bio, 500, { message: "Max 500 characters" });
|
maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });
|
||||||
|
|
||||||
// Regex pattern
|
// Regex pattern
|
||||||
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
|
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
|
||||||
message: "Format: 555-123-4567",
|
message: 'Format: 555-123-4567',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -215,7 +214,7 @@ const userForm = form(this.userModel, (schemaPath) => {
|
|||||||
```typescript
|
```typescript
|
||||||
const orderForm = form(this.orderModel, (schemaPath) => {
|
const orderForm = form(this.orderModel, (schemaPath) => {
|
||||||
required(schemaPath.promoCode, {
|
required(schemaPath.promoCode, {
|
||||||
message: "Promo code required for discounts",
|
message: 'Promo code required for discounts',
|
||||||
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
|
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -224,13 +223,13 @@ const orderForm = form(this.orderModel, (schemaPath) => {
|
|||||||
### Custom Validators
|
### Custom Validators
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { validate } from "@angular/forms/signals";
|
import { validate } from '@angular/forms/signals';
|
||||||
|
|
||||||
const signupForm = form(this.signupModel, (schemaPath) => {
|
const signupForm = form(this.signupModel, (schemaPath) => {
|
||||||
// Custom validation logic
|
// Custom validation logic
|
||||||
validate(schemaPath.username, ({ value }) => {
|
validate(schemaPath.username, ({ value }) => {
|
||||||
if (value().includes(" ")) {
|
if (value().includes(' ')) {
|
||||||
return { kind: "noSpaces", message: "Username cannot contain spaces" };
|
return { kind: 'noSpaces', message: 'Username cannot contain spaces' };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -247,7 +246,7 @@ const passwordForm = form(this.passwordModel, (schemaPath) => {
|
|||||||
// Compare fields
|
// Compare fields
|
||||||
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
|
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
|
||||||
if (value() !== valueOf(schemaPath.password)) {
|
if (value() !== valueOf(schemaPath.password)) {
|
||||||
return { kind: "mismatch", message: "Passwords do not match" };
|
return { kind: 'mismatch', message: 'Passwords do not match' };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -257,20 +256,20 @@ const passwordForm = form(this.passwordModel, (schemaPath) => {
|
|||||||
### Async Validation
|
### Async Validation
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { validateHttp } from "@angular/forms/signals";
|
import { validateHttp } from '@angular/forms/signals';
|
||||||
|
|
||||||
const signupForm = form(this.signupModel, (schemaPath) => {
|
const signupForm = form(this.signupModel, (schemaPath) => {
|
||||||
validateHttp(schemaPath.username, {
|
validateHttp(schemaPath.username, {
|
||||||
request: ({ value }) => `/api/check-username?u=${value()}`,
|
request: ({ value }) => `/api/check-username?u=${value()}`,
|
||||||
onSuccess: (response: { taken: boolean }) => {
|
onSuccess: (response: { taken: boolean }) => {
|
||||||
if (response.taken) {
|
if (response.taken) {
|
||||||
return { kind: "taken", message: "Username already taken" };
|
return { kind: 'taken', message: 'Username already taken' };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
onError: () => ({
|
onError: () => ({
|
||||||
kind: "networkError",
|
kind: 'networkError',
|
||||||
message: "Could not verify username",
|
message: 'Could not verify username',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -281,7 +280,7 @@ const signupForm = form(this.signupModel, (schemaPath) => {
|
|||||||
### Hidden Fields
|
### Hidden Fields
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { hidden } from "@angular/forms/signals";
|
import { hidden } from '@angular/forms/signals';
|
||||||
|
|
||||||
const profileForm = form(this.profileModel, (schemaPath) => {
|
const profileForm = form(this.profileModel, (schemaPath) => {
|
||||||
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
|
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
|
||||||
@@ -297,20 +296,17 @@ const profileForm = form(this.profileModel, (schemaPath) => {
|
|||||||
### Disabled Fields
|
### Disabled Fields
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { disabled } from "@angular/forms/signals";
|
import { disabled } from '@angular/forms/signals';
|
||||||
|
|
||||||
const orderForm = form(this.orderModel, (schemaPath) => {
|
const orderForm = form(this.orderModel, (schemaPath) => {
|
||||||
disabled(
|
disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
|
||||||
schemaPath.couponCode,
|
|
||||||
({ valueOf }) => valueOf(schemaPath.total) < 50,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Readonly Fields
|
### Readonly Fields
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { readonly } from "@angular/forms/signals";
|
import { readonly } from '@angular/forms/signals';
|
||||||
|
|
||||||
const accountForm = form(this.accountModel, (schemaPath) => {
|
const accountForm = form(this.accountModel, (schemaPath) => {
|
||||||
readonly(schemaPath.username); // Always readonly
|
readonly(schemaPath.username); // Always readonly
|
||||||
@@ -320,19 +316,23 @@ const accountForm = form(this.accountModel, (schemaPath) => {
|
|||||||
## Form Submission
|
## Form Submission
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { submit } from "@angular/forms/signals";
|
import { submit } from '@angular/forms/signals';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<form (submit)="onSubmit($event)">
|
<form (submit)="onSubmit($event)">
|
||||||
<input [formField]="form.email" />
|
<input [formField]="form.email" />
|
||||||
<input [formField]="form.password" />
|
<input [formField]="form.password" />
|
||||||
<button type="submit" [disabled]="form().invalid()">Submit</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="form().invalid()">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class Login {
|
export class Login {
|
||||||
model = signal({ email: "", password: "" });
|
model = signal({ email: '', password: '' });
|
||||||
form = form(this.model, (schemaPath) => {
|
form = form(this.model, (schemaPath) => {
|
||||||
required(schemaPath.email);
|
required(schemaPath.email);
|
||||||
required(schemaPath.password);
|
required(schemaPath.password);
|
||||||
@@ -360,30 +360,42 @@ interface Order {
|
|||||||
template: `
|
template: `
|
||||||
@for (item of orderForm.items; track $index; let i = $index) {
|
@for (item of orderForm.items; track $index; let i = $index) {
|
||||||
<div>
|
<div>
|
||||||
<input [formField]="item.product" placeholder="Product" />
|
<input
|
||||||
<input [formField]="item.quantity" type="number" />
|
placeholder="Product"
|
||||||
<button type="button" (click)="removeItem(i)">Remove</button>
|
[formField]="item.product" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
[formField]="item.quantity" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="removeItem(i)">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<button type="button" (click)="addItem()">Add Item</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="addItem()">
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class Order {
|
export class Order {
|
||||||
orderModel = signal<Order>({
|
orderModel = signal<Order>({
|
||||||
items: [{ product: "", quantity: 1 }],
|
items: [{ product: '', quantity: 1 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
orderForm = form(this.orderModel, (schemaPath) => {
|
orderForm = form(this.orderModel, (schemaPath) => {
|
||||||
applyEach(schemaPath.items, (item) => {
|
applyEach(schemaPath.items, (item) => {
|
||||||
required(item.product, { message: "Product required" });
|
required(item.product, { message: 'Product required' });
|
||||||
min(item.quantity, 1, { message: "Min quantity is 1" });
|
min(item.quantity, 1, { message: 'Min quantity is 1' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem() {
|
addItem() {
|
||||||
this.orderModel.update((m) => ({
|
this.orderModel.update((m) => ({
|
||||||
...m,
|
...m,
|
||||||
items: [...m.items, { product: "", quantity: 1 }],
|
items: [...m.items, { product: '', quantity: 1 }],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,8 +430,7 @@ export class Order {
|
|||||||
<input
|
<input
|
||||||
[formField]="form.email"
|
[formField]="form.email"
|
||||||
[class.is-invalid]="form.email().touched() && form.email().invalid()"
|
[class.is-invalid]="form.email().touched() && form.email().invalid()"
|
||||||
[class.is-valid]="form.email().touched() && form.email().valid()"
|
[class.is-valid]="form.email().touched() && form.email().valid()" />
|
||||||
/>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reset Form
|
## Reset Form
|
||||||
|
|||||||
@@ -14,24 +14,30 @@
|
|||||||
For production applications requiring stability guarantees, use Reactive Forms:
|
For production applications requiring stability guarantees, use Reactive Forms:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Component, inject } from "@angular/core";
|
import { Component, inject } from '@angular/core';
|
||||||
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
|
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-login",
|
selector: 'app-login',
|
||||||
imports: [ReactiveFormsModule],
|
imports: [ReactiveFormsModule],
|
||||||
template: `
|
template: `
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
<form
|
||||||
|
[formGroup]="form"
|
||||||
|
(ngSubmit)="onSubmit()">
|
||||||
<input formControlName="email" />
|
<input formControlName="email" />
|
||||||
@if (
|
@if (form.controls.email.errors?.['required'] && form.controls.email.touched) {
|
||||||
form.controls.email.errors?.["required"] && form.controls.email.touched
|
|
||||||
) {
|
|
||||||
<span class="error">Email is required</span>
|
<span class="error">Email is required</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
<input type="password" formControlName="password" />
|
<input
|
||||||
|
type="password"
|
||||||
|
formControlName="password" />
|
||||||
|
|
||||||
<button type="submit" [disabled]="form.invalid">Login</button>
|
<button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="form.invalid">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -39,8 +45,8 @@ export class Login {
|
|||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
|
|
||||||
form = this.fb.group({
|
form = this.fb.group({
|
||||||
email: ["", [Validators.required, Validators.email]],
|
email: ['', [Validators.required, Validators.email]],
|
||||||
password: ["", [Validators.required, Validators.minLength(8)]],
|
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||||
});
|
});
|
||||||
|
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
@@ -56,17 +62,17 @@ export class Login {
|
|||||||
### Typed FormControl
|
### Typed FormControl
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { FormControl } from "@angular/forms";
|
import { FormControl } from '@angular/forms';
|
||||||
|
|
||||||
// Inferred type: FormControl<string | null>
|
// Inferred type: FormControl<string | null>
|
||||||
const name = new FormControl("");
|
const name = new FormControl('');
|
||||||
|
|
||||||
// Non-nullable (no reset to null)
|
// Non-nullable (no reset to null)
|
||||||
const email = new FormControl("", { nonNullable: true });
|
const email = new FormControl('', { nonNullable: true });
|
||||||
// Type: FormControl<string>
|
// Type: FormControl<string>
|
||||||
|
|
||||||
// With validators
|
// With validators
|
||||||
const username = new FormControl("", {
|
const username = new FormControl('', {
|
||||||
nonNullable: true,
|
nonNullable: true,
|
||||||
validators: [Validators.required, Validators.minLength(3)],
|
validators: [Validators.required, Validators.minLength(3)],
|
||||||
});
|
});
|
||||||
@@ -75,7 +81,7 @@ const username = new FormControl("", {
|
|||||||
### Typed FormGroup
|
### Typed FormGroup
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { FormGroup, FormControl } from "@angular/forms";
|
import { FormGroup, FormControl } from '@angular/forms';
|
||||||
|
|
||||||
interface UserForm {
|
interface UserForm {
|
||||||
name: FormControl<string>;
|
name: FormControl<string>;
|
||||||
@@ -84,8 +90,8 @@ interface UserForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = new FormGroup<UserForm>({
|
const form = new FormGroup<UserForm>({
|
||||||
name: new FormControl("", { nonNullable: true }),
|
name: new FormControl('', { nonNullable: true }),
|
||||||
email: new FormControl("", { nonNullable: true }),
|
email: new FormControl('', { nonNullable: true }),
|
||||||
age: new FormControl<number | null>(null),
|
age: new FormControl<number | null>(null),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,13 +128,23 @@ export class Profile {
|
|||||||
@Component({
|
@Component({
|
||||||
imports: [ReactiveFormsModule],
|
imports: [ReactiveFormsModule],
|
||||||
template: `
|
template: `
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
<form
|
||||||
<input formControlName="name" placeholder="Name" />
|
[formGroup]="form"
|
||||||
|
(ngSubmit)="onSubmit()">
|
||||||
|
<input
|
||||||
|
formControlName="name"
|
||||||
|
placeholder="Name" />
|
||||||
|
|
||||||
<div formGroupName="address">
|
<div formGroupName="address">
|
||||||
<input formControlName="street" placeholder="Street" />
|
<input
|
||||||
<input formControlName="city" placeholder="City" />
|
formControlName="street"
|
||||||
<input formControlName="zip" placeholder="ZIP" />
|
placeholder="Street" />
|
||||||
|
<input
|
||||||
|
formControlName="city"
|
||||||
|
placeholder="City" />
|
||||||
|
<input
|
||||||
|
formControlName="zip"
|
||||||
|
placeholder="ZIP" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Submit</button>
|
<button type="submit">Submit</button>
|
||||||
@@ -139,11 +155,11 @@ export class Profile {
|
|||||||
private fb = inject(NonNullableFormBuilder);
|
private fb = inject(NonNullableFormBuilder);
|
||||||
|
|
||||||
form = this.fb.group({
|
form = this.fb.group({
|
||||||
name: ["", Validators.required],
|
name: ['', Validators.required],
|
||||||
address: this.fb.group({
|
address: this.fb.group({
|
||||||
street: [""],
|
street: [''],
|
||||||
city: ["", Validators.required],
|
city: ['', Validators.required],
|
||||||
zip: ["", [Validators.required, Validators.pattern(/^\d{5}$/)]],
|
zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,7 +168,7 @@ export class Profile {
|
|||||||
## Dynamic Forms with FormArray
|
## Dynamic Forms with FormArray
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { FormArray } from "@angular/forms";
|
import { FormArray } from '@angular/forms';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
imports: [ReactiveFormsModule],
|
imports: [ReactiveFormsModule],
|
||||||
@@ -161,13 +177,25 @@ import { FormArray } from "@angular/forms";
|
|||||||
<div formArrayName="items">
|
<div formArrayName="items">
|
||||||
@for (item of items.controls; track $index; let i = $index) {
|
@for (item of items.controls; track $index; let i = $index) {
|
||||||
<div [formGroupName]="i">
|
<div [formGroupName]="i">
|
||||||
<input formControlName="product" placeholder="Product" />
|
<input
|
||||||
<input formControlName="quantity" type="number" />
|
formControlName="product"
|
||||||
<button type="button" (click)="removeItem(i)">Remove</button>
|
placeholder="Product" />
|
||||||
|
<input
|
||||||
|
formControlName="quantity"
|
||||||
|
type="number" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="removeItem(i)">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" (click)="addItem()">Add Item</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="addItem()">
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -184,7 +212,7 @@ export class Order {
|
|||||||
|
|
||||||
createItem() {
|
createItem() {
|
||||||
return this.fb.group({
|
return this.fb.group({
|
||||||
product: ["", Validators.required],
|
product: ['', Validators.required],
|
||||||
quantity: [1, [Validators.required, Validators.min(1)]],
|
quantity: [1, [Validators.required, Validators.min(1)]],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -223,8 +251,8 @@ name: ['', [Validators.required, forbiddenValue('admin')]],
|
|||||||
```typescript
|
```typescript
|
||||||
export function passwordMatch(): ValidatorFn {
|
export function passwordMatch(): ValidatorFn {
|
||||||
return (group: AbstractControl): ValidationErrors | null => {
|
return (group: AbstractControl): ValidationErrors | null => {
|
||||||
const password = group.get("password")?.value;
|
const password = group.get('password')?.value;
|
||||||
const confirm = group.get("confirmPassword")?.value;
|
const confirm = group.get('confirmPassword')?.value;
|
||||||
return password === confirm ? null : { passwordMismatch: true };
|
return password === confirm ? null : { passwordMismatch: true };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -232,8 +260,8 @@ export function passwordMatch(): ValidatorFn {
|
|||||||
// Usage
|
// Usage
|
||||||
form = this.fb.group(
|
form = this.fb.group(
|
||||||
{
|
{
|
||||||
password: ["", [Validators.required, Validators.minLength(8)]],
|
password: ['', [Validators.required, Validators.minLength(8)]],
|
||||||
confirmPassword: ["", Validators.required],
|
confirmPassword: ['', Validators.required],
|
||||||
},
|
},
|
||||||
{ validators: passwordMatch() },
|
{ validators: passwordMatch() },
|
||||||
);
|
);
|
||||||
@@ -276,12 +304,12 @@ form.touched; // Control has been focused
|
|||||||
form.untouched; // Control never focused
|
form.untouched; // Control never focused
|
||||||
|
|
||||||
// Update values
|
// Update values
|
||||||
form.setValue({ name: "John", email: "john@example.com" }); // Must include all
|
form.setValue({ name: 'John', email: 'john@example.com' }); // Must include all
|
||||||
form.patchValue({ name: "John" }); // Partial update
|
form.patchValue({ name: 'John' }); // Partial update
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
form.reset();
|
form.reset();
|
||||||
form.reset({ name: "Default" });
|
form.reset({ name: 'Default' });
|
||||||
|
|
||||||
// Disable/Enable
|
// Disable/Enable
|
||||||
form.disable();
|
form.disable();
|
||||||
@@ -299,19 +327,17 @@ form.markAsDirty();
|
|||||||
```typescript
|
```typescript
|
||||||
// Subscribe to value changes
|
// Subscribe to value changes
|
||||||
form.valueChanges.subscribe((value) => {
|
form.valueChanges.subscribe((value) => {
|
||||||
console.log("Form value:", value);
|
console.log('Form value:', value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Single control with debounce
|
// Single control with debounce
|
||||||
form.controls.email.valueChanges
|
form.controls.email.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((email) => {
|
||||||
.pipe(debounceTime(300), distinctUntilChanged())
|
|
||||||
.subscribe((email) => {
|
|
||||||
this.validateEmail(email);
|
this.validateEmail(email);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Status changes
|
// Status changes
|
||||||
form.statusChanges.subscribe((status) => {
|
form.statusChanges.subscribe((status) => {
|
||||||
console.log("Form status:", status); // VALID, INVALID, PENDING
|
console.log('Form status:', status); // VALID, INVALID, PENDING
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -325,26 +351,26 @@ import {
|
|||||||
TouchedChangeEvent,
|
TouchedChangeEvent,
|
||||||
FormSubmittedEvent,
|
FormSubmittedEvent,
|
||||||
FormResetEvent,
|
FormResetEvent,
|
||||||
} from "@angular/forms";
|
} from '@angular/forms';
|
||||||
|
|
||||||
form.events.subscribe((event) => {
|
form.events.subscribe((event) => {
|
||||||
if (event instanceof ValueChangeEvent) {
|
if (event instanceof ValueChangeEvent) {
|
||||||
console.log("Value changed:", event.value);
|
console.log('Value changed:', event.value);
|
||||||
}
|
}
|
||||||
if (event instanceof StatusChangeEvent) {
|
if (event instanceof StatusChangeEvent) {
|
||||||
console.log("Status changed:", event.status);
|
console.log('Status changed:', event.status);
|
||||||
}
|
}
|
||||||
if (event instanceof PristineChangeEvent) {
|
if (event instanceof PristineChangeEvent) {
|
||||||
console.log("Pristine changed:", event.pristine);
|
console.log('Pristine changed:', event.pristine);
|
||||||
}
|
}
|
||||||
if (event instanceof TouchedChangeEvent) {
|
if (event instanceof TouchedChangeEvent) {
|
||||||
console.log("Touched changed:", event.touched);
|
console.log('Touched changed:', event.touched);
|
||||||
}
|
}
|
||||||
if (event instanceof FormSubmittedEvent) {
|
if (event instanceof FormSubmittedEvent) {
|
||||||
console.log("Form submitted");
|
console.log('Form submitted');
|
||||||
}
|
}
|
||||||
if (event instanceof FormResetEvent) {
|
if (event instanceof FormResetEvent) {
|
||||||
console.log("Form reset");
|
console.log('Form reset');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -358,10 +384,10 @@ form.events.subscribe((event) => {
|
|||||||
|
|
||||||
@if (form.controls.email.invalid && form.controls.email.touched) {
|
@if (form.controls.email.invalid && form.controls.email.touched) {
|
||||||
<div class="errors">
|
<div class="errors">
|
||||||
@if (form.controls.email.errors?.["required"]) {
|
@if (form.controls.email.errors?.['required']) {
|
||||||
<span>Email is required</span>
|
<span>Email is required</span>
|
||||||
}
|
}
|
||||||
@if (form.controls.email.errors?.["email"]) {
|
@if (form.controls.email.errors?.['email']) {
|
||||||
<span>Invalid email format</span>
|
<span>Invalid email format</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,10 +408,14 @@ export class Form {
|
|||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
<form
|
||||||
|
[formGroup]="form"
|
||||||
|
(ngSubmit)="onSubmit()">
|
||||||
<!-- fields -->
|
<!-- fields -->
|
||||||
<button type="submit" [disabled]="form.invalid || isSubmitting">
|
<button
|
||||||
{{ isSubmitting ? "Submitting..." : "Submit" }}
|
type="submit"
|
||||||
|
[disabled]="form.invalid || isSubmitting">
|
||||||
|
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -11,29 +11,22 @@ interface Rating {
|
|||||||
rating: number;
|
rating: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
import {
|
import { form, FormField, FormValueControl, ValidationError, WithOptionalField } from '@angular/forms/signals';
|
||||||
form,
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
FormField,
|
import { MatError } from '@angular/material/form-field';
|
||||||
FormValueControl,
|
|
||||||
ValidationError,
|
|
||||||
WithOptionalField,
|
|
||||||
} from "@angular/forms/signals";
|
|
||||||
import { MatIconModule } from "@angular/material/icon";
|
|
||||||
import { MatError } from "@angular/material/form-field";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-rating",
|
selector: 'app-rating',
|
||||||
imports: [MatIconModule, MatError],
|
imports: [MatIconModule, MatError],
|
||||||
template: `
|
template: `
|
||||||
<div class="star-rating-container">
|
<div class="star-rating-container">
|
||||||
@for (star of starArray(); track $index) {
|
@for (star of starArray(); track $index) {
|
||||||
<mat-icon
|
<mat-icon
|
||||||
(click)="rate(star)"
|
|
||||||
class="star-icon"
|
class="star-icon"
|
||||||
[class.readonly]="readonly()"
|
[class.readonly]="readonly()"
|
||||||
[class.error]="invalid()"
|
[class.error]="invalid()"
|
||||||
[class]="{ filled: star <= value() }"
|
[class]="{ filled: star <= value() }"
|
||||||
>
|
(click)="rate(star)">
|
||||||
{{ getStarIcon(star) }}
|
{{ getStarIcon(star) }}
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
}
|
}
|
||||||
@@ -52,8 +45,7 @@ export class Rating implements FormValueControl<number> {
|
|||||||
// Optional: Bindings for other form control states.
|
// Optional: Bindings for other form control states.
|
||||||
readonly readonly = input<boolean>(false);
|
readonly readonly = input<boolean>(false);
|
||||||
readonly invalid = input<boolean>(false);
|
readonly invalid = input<boolean>(false);
|
||||||
readonly errors: InputSignal<readonly WithOptionalField<ValidationError>[]> =
|
readonly errors: InputSignal<readonly WithOptionalField<ValidationError>[]> = input<readonly WithOptionalField<ValidationError>[]>([]);
|
||||||
input<readonly WithOptionalField<ValidationError>[]>([]);
|
|
||||||
|
|
||||||
starArray: Signal<number[]> = signal(
|
starArray: Signal<number[]> = signal(
|
||||||
Array(5)
|
Array(5)
|
||||||
@@ -64,9 +56,9 @@ export class Rating implements FormValueControl<number> {
|
|||||||
getStarIcon(index: number): string {
|
getStarIcon(index: number): string {
|
||||||
const floorRating = Math.floor(this.value());
|
const floorRating = Math.floor(this.value());
|
||||||
if (index <= floorRating) {
|
if (index <= floorRating) {
|
||||||
return "star"; // Full star
|
return 'star'; // Full star
|
||||||
} else {
|
} else {
|
||||||
return "star_border"; // Empty star
|
return 'star_border'; // Empty star
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rate(index: number): void {
|
rate(index: number): void {
|
||||||
@@ -76,13 +68,15 @@ export class Rating implements FormValueControl<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import { FormField } from "@angular/forms/signals";
|
import { FormField } from '@angular/forms/signals';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-signal-forms",
|
selector: 'app-signal-forms',
|
||||||
imports: [FormField, Rating],
|
imports: [FormField, Rating],
|
||||||
template: `
|
template: `
|
||||||
<form autocomplete="off" (submit)="submit($event)">
|
<form
|
||||||
|
autocomplete="off"
|
||||||
|
(submit)="submit($event)">
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<app-rating [formField]="ratingForm.rating"> </app-rating>
|
<app-rating [formField]="ratingForm.rating"> </app-rating>
|
||||||
<!-- print to show the value updation -->
|
<!-- print to show the value updation -->
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ Fetch data in Angular using signal-based `resource()`, `httpResource()`, and the
|
|||||||
`httpResource()` wraps HttpClient with signal-based state management:
|
`httpResource()` wraps HttpClient with signal-based state management:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Component, signal } from "@angular/core";
|
import { Component, signal } from '@angular/core';
|
||||||
import { httpResource } from "@angular/common/http";
|
import { httpResource } from '@angular/common/http';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,7 +22,7 @@ interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-user-profile",
|
selector: 'app-user-profile',
|
||||||
template: `
|
template: `
|
||||||
@if (userResource.isLoading()) {
|
@if (userResource.isLoading()) {
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
@@ -36,7 +36,7 @@ interface User {
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class UserProfile {
|
export class UserProfile {
|
||||||
userId = signal("123");
|
userId = signal('123');
|
||||||
|
|
||||||
// Reactive HTTP resource - refetches when userId changes
|
// Reactive HTTP resource - refetches when userId changes
|
||||||
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
|
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
|
||||||
@@ -52,13 +52,13 @@ userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
|
|||||||
// With full request options
|
// With full request options
|
||||||
userResource = httpResource<User>(() => ({
|
userResource = httpResource<User>(() => ({
|
||||||
url: `/api/users/${this.userId()}`,
|
url: `/api/users/${this.userId()}`,
|
||||||
method: "GET",
|
method: 'GET',
|
||||||
headers: { Authorization: `Bearer ${this.token()}` },
|
headers: { Authorization: `Bearer ${this.token()}` },
|
||||||
params: { include: "profile" },
|
params: { include: 'profile' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// With default value
|
// With default value
|
||||||
usersResource = httpResource<User[]>(() => "/api/users", {
|
usersResource = httpResource<User[]>(() => '/api/users', {
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,18 +204,18 @@ deleteUser(id: string) {
|
|||||||
### Request Options
|
### Request Options
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
this.http.get<User[]>("/api/users", {
|
this.http.get<User[]>('/api/users', {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer token",
|
'Authorization': 'Bearer token',
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
params: {
|
params: {
|
||||||
page: "1",
|
page: '1',
|
||||||
limit: "10",
|
limit: '10',
|
||||||
sort: "name",
|
sort: 'name',
|
||||||
},
|
},
|
||||||
observe: "response", // Get full HttpResponse
|
observe: 'response', // Get full HttpResponse
|
||||||
responseType: "json",
|
responseType: 'json',
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -225,8 +225,8 @@ this.http.get<User[]>("/api/users", {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// auth.interceptor.ts
|
// auth.interceptor.ts
|
||||||
import { HttpInterceptorFn } from "@angular/common/http";
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
import { inject } from "@angular/core";
|
import { inject } from '@angular/core';
|
||||||
|
|
||||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
const authService = inject(Auth);
|
const authService = inject(Auth);
|
||||||
@@ -246,7 +246,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
return next(req).pipe(
|
return next(req).pipe(
|
||||||
catchError((error: HttpErrorResponse) => {
|
catchError((error: HttpErrorResponse) => {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
inject(Router).navigate(["/login"]);
|
inject(Router).navigate(['/login']);
|
||||||
}
|
}
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
}),
|
}),
|
||||||
@@ -258,8 +258,7 @@ export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
return next(req).pipe(
|
return next(req).pipe(
|
||||||
tap({
|
tap({
|
||||||
next: () =>
|
next: () => console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`),
|
||||||
console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`),
|
|
||||||
error: (err) => console.error(`${req.method} ${req.url} failed`, err),
|
error: (err) => console.error(`${req.method} ${req.url} failed`, err),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -270,14 +269,10 @@ export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// app.config.ts
|
// app.config.ts
|
||||||
import { provideHttpClient, withInterceptors } from "@angular/common/http";
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [provideHttpClient(withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor]))],
|
||||||
provideHttpClient(
|
|
||||||
withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -301,11 +296,9 @@ export class UserCmpt {
|
|||||||
|
|
||||||
getErrorMessage(error: unknown): string {
|
getErrorMessage(error: unknown): string {
|
||||||
if (error instanceof HttpErrorResponse) {
|
if (error instanceof HttpErrorResponse) {
|
||||||
return (
|
return error.error?.message || `Error ${error.status}: ${error.statusText}`;
|
||||||
error.error?.message || `Error ${error.status}: ${error.statusText}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return "An unexpected error occurred";
|
return 'An unexpected error occurred';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -332,33 +325,30 @@ getUser(id: string) {
|
|||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
@switch (dataResource.status()) {
|
@switch (dataResource.status()) {
|
||||||
@case ("idle") {
|
@case ('idle') {
|
||||||
<p>Enter a search term</p>
|
<p>Enter a search term</p>
|
||||||
}
|
}
|
||||||
@case ("loading") {
|
@case ('loading') {
|
||||||
<app-spinner />
|
<app-spinner />
|
||||||
}
|
}
|
||||||
@case ("reloading") {
|
@case ('reloading') {
|
||||||
<app-data [data]="dataResource.value()" />
|
<app-data [data]="dataResource.value()" />
|
||||||
<app-spinner size="small" />
|
<app-spinner size="small" />
|
||||||
}
|
}
|
||||||
@case ("resolved") {
|
@case ('resolved') {
|
||||||
<app-data [data]="dataResource.value()" />
|
<app-data [data]="dataResource.value()" />
|
||||||
}
|
}
|
||||||
@case ("error") {
|
@case ('error') {
|
||||||
<app-error
|
<app-error
|
||||||
[error]="dataResource.error()"
|
[error]="dataResource.error()"
|
||||||
(retry)="dataResource.reload()"
|
(retry)="dataResource.reload()" />
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class Data {
|
export class Data {
|
||||||
query = signal("");
|
query = signal('');
|
||||||
dataResource = httpResource<Data[]>(() =>
|
dataResource = httpResource<Data[]>(() => (this.query() ? `/api/search?q=${this.query()}` : undefined));
|
||||||
this.query() ? `/api/search?q=${this.query()}` : undefined,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
Encapsulate HTTP logic in services:
|
Encapsulate HTTP logic in services:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Injectable, inject, signal, computed } from "@angular/core";
|
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||||
import { HttpClient } from "@angular/common/http";
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { httpResource } from "@angular/common/http";
|
import { httpResource } from '@angular/common/http';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,10 +24,10 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class User {
|
export class User {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private baseUrl = "/api/users";
|
private baseUrl = '/api/users';
|
||||||
|
|
||||||
// Current user ID for reactive fetching
|
// Current user ID for reactive fetching
|
||||||
private currentUserId = signal<string | null>(null);
|
private currentUserId = signal<string | null>(null);
|
||||||
@@ -52,7 +52,7 @@ export class User {
|
|||||||
return this.http.get<User>(`${this.baseUrl}/${id}`);
|
return this.http.get<User>(`${this.baseUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(user: Omit<User, "id">) {
|
create(user: Omit<User, 'id'>) {
|
||||||
return this.http.post<User>(this.baseUrl, user);
|
return this.http.post<User>(this.baseUrl, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export class User {
|
|||||||
### Simple In-Memory Cache
|
### Simple In-Memory Cache
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CachedUser {
|
export class CachedUser {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private cache = new Map<string, { data: User; timestamp: number }>();
|
private cache = new Map<string, { data: User; timestamp: number }>();
|
||||||
@@ -104,7 +104,7 @@ export class CachedUser {
|
|||||||
### Signal-Based Cache
|
### Signal-Based Cache
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class UserCache {
|
export class UserCache {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
@@ -160,14 +160,17 @@ interface PaginatedResponse<T> {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<button (click)="prevPage()" [disabled]="page() === 1">Previous</button>
|
<button
|
||||||
|
[disabled]="page() === 1"
|
||||||
|
(click)="prevPage()">
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
<span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>
|
<span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
(click)="nextPage()"
|
|
||||||
[disabled]="page() >= usersResource.value().totalPages"
|
[disabled]="page() >= usersResource.value().totalPages"
|
||||||
>
|
(click)="nextPage()">
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +182,7 @@ export class UsersList {
|
|||||||
pageSize = signal(10);
|
pageSize = signal(10);
|
||||||
|
|
||||||
usersResource = httpResource<PaginatedResponse<User>>(() => ({
|
usersResource = httpResource<PaginatedResponse<User>>(() => ({
|
||||||
url: "/api/users",
|
url: '/api/users',
|
||||||
params: {
|
params: {
|
||||||
page: this.page().toString(),
|
page: this.page().toString(),
|
||||||
pageSize: this.pageSize().toString(),
|
pageSize: this.pageSize().toString(),
|
||||||
@@ -240,8 +243,8 @@ export class InfiniteUsers {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.get<PaginatedResponse<User>>("/api/users", {
|
this.http.get<PaginatedResponse<User>>('/api/users', {
|
||||||
params: { page: page.toString(), pageSize: "20" },
|
params: { page: page.toString(), pageSize: '20' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -262,10 +265,14 @@ export class InfiniteUsers {
|
|||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<input type="file" (change)="onFileSelected($event)" />
|
<input
|
||||||
|
type="file"
|
||||||
|
(change)="onFileSelected($event)" />
|
||||||
|
|
||||||
@if (uploadProgress() !== null) {
|
@if (uploadProgress() !== null) {
|
||||||
<progress [value]="uploadProgress()" max="100"></progress>
|
<progress
|
||||||
|
max="100"
|
||||||
|
[value]="uploadProgress()"></progress>
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -279,21 +286,19 @@ export class FileUpload {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append('file', file);
|
||||||
|
|
||||||
this.http
|
this.http
|
||||||
.post("/api/upload", formData, {
|
.post('/api/upload', formData, {
|
||||||
reportProgress: true,
|
reportProgress: true,
|
||||||
observe: "events",
|
observe: 'events',
|
||||||
})
|
})
|
||||||
.subscribe((event) => {
|
.subscribe((event) => {
|
||||||
if (event.type === HttpEventType.UploadProgress && event.total) {
|
if (event.type === HttpEventType.UploadProgress && event.total) {
|
||||||
this.uploadProgress.set(
|
this.uploadProgress.set(Math.round((100 * event.loaded) / event.total));
|
||||||
Math.round((100 * event.loaded) / event.total),
|
|
||||||
);
|
|
||||||
} else if (event.type === HttpEventType.Response) {
|
} else if (event.type === HttpEventType.Response) {
|
||||||
this.uploadProgress.set(null);
|
this.uploadProgress.set(null);
|
||||||
console.log("Upload complete:", event.body);
|
console.log('Upload complete:', event.body);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -383,7 +388,7 @@ export class SearchDebounced {
|
|||||||
### Testing httpResource
|
### Testing httpResource
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
describe("UserCmpt", () => {
|
describe('UserCmpt', () => {
|
||||||
let component: UserCmpt;
|
let component: UserCmpt;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
@@ -397,13 +402,13 @@ describe("UserCmpt", () => {
|
|||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should load user", () => {
|
it('should load user', () => {
|
||||||
component.userId.set("123");
|
component.userId.set('123');
|
||||||
|
|
||||||
const req = httpMock.expectOne("/api/users/123");
|
const req = httpMock.expectOne('/api/users/123');
|
||||||
req.flush({ id: "123", name: "Test User" });
|
req.flush({ id: '123', name: 'Test User' });
|
||||||
|
|
||||||
expect(component.userResource.value()?.name).toBe("Test User");
|
expect(component.userResource.value()?.name).toBe('Test User');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -415,7 +420,7 @@ describe("UserCmpt", () => {
|
|||||||
### Testing Services
|
### Testing Services
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
describe("User", () => {
|
describe('User', () => {
|
||||||
let service: User;
|
let service: User;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
@@ -428,19 +433,19 @@ describe("User", () => {
|
|||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create user", () => {
|
it('should create user', () => {
|
||||||
const newUser = { name: "Test", email: "test@example.com" };
|
const newUser = { name: 'Test', email: 'test@example.com' };
|
||||||
|
|
||||||
service.create(newUser).subscribe((user) => {
|
service.create(newUser).subscribe((user) => {
|
||||||
expect(user.id).toBeDefined();
|
expect(user.id).toBeDefined();
|
||||||
expect(user.name).toBe("Test");
|
expect(user.name).toBe('Test');
|
||||||
});
|
});
|
||||||
|
|
||||||
const req = httpMock.expectOne("/api/users");
|
const req = httpMock.expectOne('/api/users');
|
||||||
expect(req.request.method).toBe("POST");
|
expect(req.request.method).toBe('POST');
|
||||||
expect(req.request.body).toEqual(newUser);
|
expect(req.request.body).toEqual(newUser);
|
||||||
|
|
||||||
req.flush({ id: "1", ...newUser });
|
req.flush({ id: '1', ...newUser });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -11,35 +11,43 @@ Configure routing in Angular v20+ with lazy loading, functional guards, and sign
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// app.routes.ts
|
// app.routes.ts
|
||||||
import { Routes } from "@angular/router";
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: "", redirectTo: "/home", pathMatch: "full" },
|
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||||
{ path: "home", component: Home },
|
{ path: 'home', component: Home },
|
||||||
{ path: "about", component: About },
|
{ path: 'about', component: About },
|
||||||
{ path: "**", component: NotFound },
|
{ path: '**', component: NotFound },
|
||||||
];
|
];
|
||||||
|
|
||||||
// app.config.ts
|
// app.config.ts
|
||||||
import { ApplicationConfig } from "@angular/core";
|
import { ApplicationConfig } from '@angular/core';
|
||||||
import { provideRouter } from "@angular/router";
|
import { provideRouter } from '@angular/router';
|
||||||
import { routes } from "./app.routes";
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [provideRouter(routes)],
|
providers: [provideRouter(routes)],
|
||||||
};
|
};
|
||||||
|
|
||||||
// app.component.ts
|
// app.component.ts
|
||||||
import { Component } from "@angular/core";
|
import { Component } from '@angular/core';
|
||||||
import { RouterOutlet, RouterLink, RouterLinkActive } from "@angular/router";
|
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-root",
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||||
template: `
|
template: `
|
||||||
<nav>
|
<nav>
|
||||||
<a routerLink="/home" routerLinkActive="active">Home</a>
|
<a
|
||||||
<a routerLink="/about" routerLinkActive="active">About</a>
|
routerLink="/home"
|
||||||
|
routerLinkActive="active"
|
||||||
|
>Home</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/about"
|
||||||
|
routerLinkActive="active"
|
||||||
|
>About</a
|
||||||
|
>
|
||||||
</nav>
|
</nav>
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
`,
|
`,
|
||||||
@@ -54,29 +62,27 @@ Load feature modules on demand:
|
|||||||
```typescript
|
```typescript
|
||||||
// app.routes.ts
|
// app.routes.ts
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: "", redirectTo: "/home", pathMatch: "full" },
|
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||||
{ path: "home", component: Home },
|
{ path: 'home', component: Home },
|
||||||
|
|
||||||
// Lazy load entire feature
|
// Lazy load entire feature
|
||||||
{
|
{
|
||||||
path: "admin",
|
path: 'admin',
|
||||||
loadChildren: () =>
|
loadChildren: () => import('./admin/admin.routes').then((m) => m.adminRoutes),
|
||||||
import("./admin/admin.routes").then((m) => m.adminRoutes),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Lazy load single component
|
// Lazy load single component
|
||||||
{
|
{
|
||||||
path: "settings",
|
path: 'settings',
|
||||||
loadComponent: () =>
|
loadComponent: () => import('./settings/settings.component').then((m) => m.Settings),
|
||||||
import("./settings/settings.component").then((m) => m.Settings),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// admin/admin.routes.ts
|
// admin/admin.routes.ts
|
||||||
export const adminRoutes: Routes = [
|
export const adminRoutes: Routes = [
|
||||||
{ path: "", component: AdminDashboard },
|
{ path: '', component: AdminDashboard },
|
||||||
{ path: "users", component: AdminUsers },
|
{ path: 'users', component: AdminUsers },
|
||||||
{ path: "settings", component: AdminSettings },
|
{ path: 'settings', component: AdminSettings },
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -110,7 +116,7 @@ Enable with `withComponentInputBinding()`:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// app.config.ts
|
// app.config.ts
|
||||||
import { provideRouter, withComponentInputBinding } from "@angular/router";
|
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [provideRouter(routes, withComponentInputBinding())],
|
providers: [provideRouter(routes, withComponentInputBinding())],
|
||||||
@@ -283,12 +289,12 @@ export class UserDetail {
|
|||||||
// Parent route with children
|
// Parent route with children
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: "products",
|
path: 'products',
|
||||||
component: ProductsLayout,
|
component: ProductsLayout,
|
||||||
children: [
|
children: [
|
||||||
{ path: "", component: ProductList },
|
{ path: '', component: ProductList },
|
||||||
{ path: ":id", component: ProductDetail },
|
{ path: ':id', component: ProductDetail },
|
||||||
{ path: ":id/edit", component: ProductEdit },
|
{ path: ':id/edit', component: ProductEdit },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
```typescript
|
```typescript
|
||||||
export const userTitleResolver: ResolveFn<string> = (route) => {
|
export const userTitleResolver: ResolveFn<string> = (route) => {
|
||||||
const userService = inject(User);
|
const userService = inject(User);
|
||||||
const id = route.paramMap.get("id")!;
|
const id = route.paramMap.get('id')!;
|
||||||
return userService.getById(id).pipe(map((user) => `${user.name} - Profile`));
|
return userService.getById(id).pipe(map((user) => `${user.name} - Profile`));
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -64,7 +64,7 @@ export const userTitleResolver: ResolveFn<string> = (route) => {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// auth.service.ts
|
// auth.service.ts
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class Auth {
|
export class Auth {
|
||||||
private _user = signal<User | null>(null);
|
private _user = signal<User | null>(null);
|
||||||
private _token = signal<string | null>(null);
|
private _token = signal<string | null>(null);
|
||||||
@@ -77,13 +77,11 @@ export class Auth {
|
|||||||
|
|
||||||
async login(credentials: Credentials): Promise<boolean> {
|
async login(credentials: Credentials): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(this.http.post<AuthResponse>('/api/login', credentials));
|
||||||
this.http.post<AuthResponse>("/api/login", credentials),
|
|
||||||
);
|
|
||||||
|
|
||||||
this._token.set(response.token);
|
this._token.set(response.token);
|
||||||
this._user.set(response.user);
|
this._user.set(response.user);
|
||||||
localStorage.setItem("token", response.token);
|
localStorage.setItem('token', response.token);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -94,21 +92,21 @@ export class Auth {
|
|||||||
logout(): void {
|
logout(): void {
|
||||||
this._user.set(null);
|
this._user.set(null);
|
||||||
this._token.set(null);
|
this._token.set(null);
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem('token');
|
||||||
this.router.navigate(["/login"]);
|
this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkAuth(): Promise<boolean> {
|
async checkAuth(): Promise<boolean> {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await firstValueFrom(this.http.get<User>("/api/me"));
|
const user = await firstValueFrom(this.http.get<User>('/api/me'));
|
||||||
this._user.set(user);
|
this._user.set(user);
|
||||||
this._token.set(token);
|
this._token.set(token);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem('token');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +129,7 @@ export const authGuard: CanActivateFn = async (route, state) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login
|
// Redirect to login
|
||||||
return router.createUrlTree(["/login"], {
|
return router.createUrlTree(['/login'], {
|
||||||
queryParams: { returnUrl: state.url },
|
queryParams: { returnUrl: state.url },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -140,8 +138,13 @@ export const authGuard: CanActivateFn = async (route, state) => {
|
|||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<form (ngSubmit)="login()">
|
<form (ngSubmit)="login()">
|
||||||
<input [(ngModel)]="email" name="email" />
|
<input
|
||||||
<input [(ngModel)]="password" name="password" type="password" />
|
name="email"
|
||||||
|
[(ngModel)]="email" />
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="password" />
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
@@ -151,8 +154,8 @@ export class Login {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
email = "";
|
email = '';
|
||||||
password = "";
|
password = '';
|
||||||
|
|
||||||
async login() {
|
async login() {
|
||||||
const success = await this.authService.login({
|
const success = await this.authService.login({
|
||||||
@@ -161,7 +164,7 @@ export class Login {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
const returnUrl = this.route.snapshot.queryParams["returnUrl"] || "/";
|
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
|
||||||
this.router.navigateByUrl(returnUrl);
|
this.router.navigateByUrl(returnUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +175,7 @@ export class Login {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// breadcrumb.service.ts
|
// breadcrumb.service.ts
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class Breadcrumb {
|
export class Breadcrumb {
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
@@ -185,11 +188,7 @@ export class Breadcrumb {
|
|||||||
{ initialValue: [] },
|
{ initialValue: [] },
|
||||||
);
|
);
|
||||||
|
|
||||||
private buildBreadcrumbs(
|
private buildBreadcrumbs(route: ActivatedRoute, url: string = '', breadcrumbs: Breadcrumb[] = []): Breadcrumb[] {
|
||||||
route: ActivatedRoute,
|
|
||||||
url: string = "",
|
|
||||||
breadcrumbs: Breadcrumb[] = [],
|
|
||||||
): Breadcrumb[] {
|
|
||||||
const children = route.children;
|
const children = route.children;
|
||||||
|
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
@@ -197,15 +196,13 @@ export class Breadcrumb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const routeUrl = child.snapshot.url
|
const routeUrl = child.snapshot.url.map((segment) => segment.path).join('/');
|
||||||
.map((segment) => segment.path)
|
|
||||||
.join("/");
|
|
||||||
|
|
||||||
if (routeUrl) {
|
if (routeUrl) {
|
||||||
url += `/${routeUrl}`;
|
url += `/${routeUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = child.snapshot.data["breadcrumb"];
|
const label = child.snapshot.data['breadcrumb'];
|
||||||
if (label) {
|
if (label) {
|
||||||
breadcrumbs.push({ label, url });
|
breadcrumbs.push({ label, url });
|
||||||
}
|
}
|
||||||
@@ -220,13 +217,13 @@ export class Breadcrumb {
|
|||||||
// Route config with breadcrumb data
|
// Route config with breadcrumb data
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: "products",
|
path: 'products',
|
||||||
data: { breadcrumb: "Products" },
|
data: { breadcrumb: 'Products' },
|
||||||
children: [
|
children: [
|
||||||
{ path: "", component: ProductList },
|
{ path: '', component: ProductList },
|
||||||
{
|
{
|
||||||
path: ":id",
|
path: ':id',
|
||||||
data: { breadcrumb: "Product Details" },
|
data: { breadcrumb: 'Product Details' },
|
||||||
component: ProductDetail,
|
component: ProductDetail,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -235,7 +232,7 @@ export const routes: Routes = [
|
|||||||
|
|
||||||
// breadcrumb.component.ts
|
// breadcrumb.component.ts
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-breadcrumb",
|
selector: 'app-breadcrumb',
|
||||||
template: `
|
template: `
|
||||||
<nav aria-label="Breadcrumb">
|
<nav aria-label="Breadcrumb">
|
||||||
<ol>
|
<ol>
|
||||||
@@ -334,12 +331,7 @@ this.router.navigate([{ outlets: { modal: null } }]);
|
|||||||
### Built-in Strategies
|
### Built-in Strategies
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import { provideRouter, withPreloading, PreloadAllModules, NoPreloading } from '@angular/router';
|
||||||
provideRouter,
|
|
||||||
withPreloading,
|
|
||||||
PreloadAllModules,
|
|
||||||
NoPreloading,
|
|
||||||
} from "@angular/router";
|
|
||||||
|
|
||||||
// Preload all lazy modules
|
// Preload all lazy modules
|
||||||
provideRouter(routes, withPreloading(PreloadAllModules));
|
provideRouter(routes, withPreloading(PreloadAllModules));
|
||||||
@@ -377,7 +369,7 @@ provideRouter(routes, withPreloading(SelectivePreloadStrategy))
|
|||||||
### Network-Aware Preloading
|
### Network-Aware Preloading
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class NetworkAwarePreloadStrategy implements PreloadingStrategy {
|
export class NetworkAwarePreloadStrategy implements PreloadingStrategy {
|
||||||
preload(route: Route, load: () => Observable<any>): Observable<any> {
|
preload(route: Route, load: () => Observable<any>): Observable<any> {
|
||||||
// Check network conditions
|
// Check network conditions
|
||||||
@@ -385,13 +377,13 @@ export class NetworkAwarePreloadStrategy implements PreloadingStrategy {
|
|||||||
|
|
||||||
if (connection) {
|
if (connection) {
|
||||||
// Don't preload on slow connections
|
// Don't preload on slow connections
|
||||||
if (connection.saveData || connection.effectiveType === "2g") {
|
if (connection.saveData || connection.effectiveType === '2g') {
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preload if marked
|
// Preload if marked
|
||||||
if (route.data?.["preload"]) {
|
if (route.data?.['preload']) {
|
||||||
return load();
|
return load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,8 +397,8 @@ export class NetworkAwarePreloadStrategy implements PreloadingStrategy {
|
|||||||
```typescript
|
```typescript
|
||||||
// app.routes.ts
|
// app.routes.ts
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: "home", component: Home, data: { animation: "HomePage" } },
|
{ path: 'home', component: Home, data: { animation: 'HomePage' } },
|
||||||
{ path: "about", component: About, data: { animation: "AboutPage" } },
|
{ path: 'about', component: About, data: { animation: 'AboutPage' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
// app.component.ts
|
// app.component.ts
|
||||||
@@ -418,22 +410,22 @@ export const routes: Routes = [
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
animations: [
|
animations: [
|
||||||
trigger("routeAnimations", [
|
trigger('routeAnimations', [
|
||||||
transition("HomePage <=> AboutPage", [
|
transition('HomePage <=> AboutPage', [
|
||||||
style({ position: "relative" }),
|
style({ position: 'relative' }),
|
||||||
query(":enter, :leave", [
|
query(':enter, :leave', [
|
||||||
style({
|
style({
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
query(":enter", [style({ left: "-100%" })]),
|
query(':enter', [style({ left: '-100%' })]),
|
||||||
query(":leave", animateChild()),
|
query(':leave', animateChild()),
|
||||||
group([
|
group([
|
||||||
query(":leave", [animate("300ms ease-out", style({ left: "100%" }))]),
|
query(':leave', [animate('300ms ease-out', style({ left: '100%' }))]),
|
||||||
query(":enter", [animate("300ms ease-out", style({ left: "0%" }))]),
|
query(':enter', [animate('300ms ease-out', style({ left: '0%' }))]),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
@@ -441,7 +433,7 @@ export const routes: Routes = [
|
|||||||
})
|
})
|
||||||
export class AppMain {
|
export class AppMain {
|
||||||
getRouteAnimationData() {
|
getRouteAnimationData() {
|
||||||
return this.route.firstChild?.snapshot.data["animation"];
|
return this.route.firstChild?.snapshot.data['animation'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -450,20 +442,16 @@ export class AppMain {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// app.config.ts
|
// app.config.ts
|
||||||
import {
|
import { provideRouter, withInMemoryScrolling, withRouterConfig } from '@angular/router';
|
||||||
provideRouter,
|
|
||||||
withInMemoryScrolling,
|
|
||||||
withRouterConfig,
|
|
||||||
} from "@angular/router";
|
|
||||||
|
|
||||||
provideRouter(
|
provideRouter(
|
||||||
routes,
|
routes,
|
||||||
withInMemoryScrolling({
|
withInMemoryScrolling({
|
||||||
scrollPositionRestoration: "enabled", // or 'top'
|
scrollPositionRestoration: 'enabled', // or 'top'
|
||||||
anchorScrolling: "enabled",
|
anchorScrolling: 'enabled',
|
||||||
}),
|
}),
|
||||||
withRouterConfig({
|
withRouterConfig({
|
||||||
onSameUrlNavigation: "reload",
|
onSameUrlNavigation: 'reload',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Signals are Angular's reactive primitive for state management. They provide sync
|
|||||||
### signal() - Writable State
|
### signal() - Writable State
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { signal } from "@angular/core";
|
import { signal } from '@angular/core';
|
||||||
|
|
||||||
// Create writable signal
|
// Create writable signal
|
||||||
const count = signal(0);
|
const count = signal(0);
|
||||||
@@ -28,52 +28,50 @@ count.update((c) => c + 1);
|
|||||||
|
|
||||||
// With explicit type
|
// With explicit type
|
||||||
const user = signal<User | null>(null);
|
const user = signal<User | null>(null);
|
||||||
user.set({ id: 1, name: "Alice" });
|
user.set({ id: 1, name: 'Alice' });
|
||||||
```
|
```
|
||||||
|
|
||||||
### computed() - Derived State
|
### computed() - Derived State
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { signal, computed } from "@angular/core";
|
import { signal, computed } from '@angular/core';
|
||||||
|
|
||||||
const firstName = signal("John");
|
const firstName = signal('John');
|
||||||
const lastName = signal("Doe");
|
const lastName = signal('Doe');
|
||||||
|
|
||||||
// Derived signal - automatically updates when dependencies change
|
// Derived signal - automatically updates when dependencies change
|
||||||
const fullName = computed(() => `${firstName()} ${lastName()}`);
|
const fullName = computed(() => `${firstName()} ${lastName()}`);
|
||||||
|
|
||||||
console.log(fullName()); // "John Doe"
|
console.log(fullName()); // "John Doe"
|
||||||
firstName.set("Jane");
|
firstName.set('Jane');
|
||||||
console.log(fullName()); // "Jane Doe"
|
console.log(fullName()); // "Jane Doe"
|
||||||
|
|
||||||
// Computed with complex logic
|
// Computed with complex logic
|
||||||
const items = signal<Item[]>([]);
|
const items = signal<Item[]>([]);
|
||||||
const filter = signal("");
|
const filter = signal('');
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
const query = filter().toLowerCase();
|
const query = filter().toLowerCase();
|
||||||
return items().filter((item) => item.name.toLowerCase().includes(query));
|
return items().filter((item) => item.name.toLowerCase().includes(query));
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPrice = computed(() =>
|
const totalPrice = computed(() => filteredItems().reduce((sum, item) => sum + item.price, 0));
|
||||||
filteredItems().reduce((sum, item) => sum + item.price, 0),
|
|
||||||
);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### linkedSignal() - Dependent State with Reset
|
### linkedSignal() - Dependent State with Reset
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { signal, linkedSignal } from "@angular/core";
|
import { signal, linkedSignal } from '@angular/core';
|
||||||
|
|
||||||
const options = signal(["A", "B", "C"]);
|
const options = signal(['A', 'B', 'C']);
|
||||||
|
|
||||||
// Resets to first option when options change
|
// Resets to first option when options change
|
||||||
const selected = linkedSignal(() => options()[0]);
|
const selected = linkedSignal(() => options()[0]);
|
||||||
|
|
||||||
console.log(selected()); // "A"
|
console.log(selected()); // "A"
|
||||||
selected.set("B"); // User selects B
|
selected.set('B'); // User selects B
|
||||||
console.log(selected()); // "B"
|
console.log(selected()); // "B"
|
||||||
options.set(["X", "Y"]); // Options change
|
options.set(['X', 'Y']); // Options change
|
||||||
console.log(selected()); // "X" - auto-reset to first
|
console.log(selected()); // "X" - auto-reset to first
|
||||||
|
|
||||||
// With previous value access
|
// With previous value access
|
||||||
@@ -128,13 +126,16 @@ export class Search {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-todo-list",
|
selector: 'app-todo-list',
|
||||||
template: `
|
template: `
|
||||||
<input
|
<input
|
||||||
[value]="newTodo()"
|
[value]="newTodo()"
|
||||||
(input)="newTodo.set($any($event.target).value)"
|
(input)="newTodo.set($any($event.target).value)" />
|
||||||
/>
|
<button
|
||||||
<button (click)="addTodo()" [disabled]="!canAdd()">Add</button>
|
[disabled]="!canAdd()"
|
||||||
|
(click)="addTodo()">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
@for (todo of filteredTodos(); track todo.id) {
|
@for (todo of filteredTodos(); track todo.id) {
|
||||||
@@ -151,8 +152,8 @@ export class Search {
|
|||||||
export class TodoList {
|
export class TodoList {
|
||||||
// State
|
// State
|
||||||
todos = signal<Todo[]>([]);
|
todos = signal<Todo[]>([]);
|
||||||
newTodo = signal("");
|
newTodo = signal('');
|
||||||
filter = signal<"all" | "active" | "done">("all");
|
filter = signal<'all' | 'active' | 'done'>('all');
|
||||||
|
|
||||||
// Derived state
|
// Derived state
|
||||||
canAdd = computed(() => this.newTodo().trim().length > 0);
|
canAdd = computed(() => this.newTodo().trim().length > 0);
|
||||||
@@ -160,9 +161,9 @@ export class TodoList {
|
|||||||
filteredTodos = computed(() => {
|
filteredTodos = computed(() => {
|
||||||
const todos = this.todos();
|
const todos = this.todos();
|
||||||
switch (this.filter()) {
|
switch (this.filter()) {
|
||||||
case "active":
|
case 'active':
|
||||||
return todos.filter((t) => !t.done);
|
return todos.filter((t) => !t.done);
|
||||||
case "done":
|
case 'done':
|
||||||
return todos.filter((t) => t.done);
|
return todos.filter((t) => t.done);
|
||||||
default:
|
default:
|
||||||
return todos;
|
return todos;
|
||||||
@@ -175,18 +176,13 @@ export class TodoList {
|
|||||||
addTodo() {
|
addTodo() {
|
||||||
const text = this.newTodo().trim();
|
const text = this.newTodo().trim();
|
||||||
if (text) {
|
if (text) {
|
||||||
this.todos.update((todos) => [
|
this.todos.update((todos) => [...todos, { id: crypto.randomUUID(), text, done: false }]);
|
||||||
...todos,
|
this.newTodo.set('');
|
||||||
{ id: crypto.randomUUID(), text, done: false },
|
|
||||||
]);
|
|
||||||
this.newTodo.set("");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleTodo(id: string) {
|
toggleTodo(id: string) {
|
||||||
this.todos.update((todos) =>
|
this.todos.update((todos) => todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
|
||||||
todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -242,20 +238,17 @@ export class Search {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Custom equality function
|
// Custom equality function
|
||||||
const user = signal<User>(
|
const user = signal<User>({ id: 1, name: 'Alice' }, { equal: (a, b) => a.id === b.id });
|
||||||
{ id: 1, name: "Alice" },
|
|
||||||
{ equal: (a, b) => a.id === b.id },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only triggers updates when ID changes
|
// Only triggers updates when ID changes
|
||||||
user.set({ id: 1, name: "Alice Updated" }); // No update
|
user.set({ id: 1, name: 'Alice Updated' }); // No update
|
||||||
user.set({ id: 2, name: "Bob" }); // Triggers update
|
user.set({ id: 2, name: 'Bob' }); // Triggers update
|
||||||
```
|
```
|
||||||
|
|
||||||
## Untracked Reads
|
## Untracked Reads
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { untracked } from "@angular/core";
|
import { untracked } from '@angular/core';
|
||||||
|
|
||||||
const a = signal(1);
|
const a = signal(1);
|
||||||
const b = signal(2);
|
const b = signal(2);
|
||||||
@@ -271,7 +264,7 @@ const result = computed(() => {
|
|||||||
## Service State Pattern
|
## Service State Pattern
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class Auth {
|
export class Auth {
|
||||||
// Private writable state
|
// Private writable state
|
||||||
private _user = signal<User | null>(null);
|
private _user = signal<User | null>(null);
|
||||||
@@ -287,9 +280,7 @@ export class Auth {
|
|||||||
async login(credentials: Credentials): Promise<void> {
|
async login(credentials: Credentials): Promise<void> {
|
||||||
this._loading.set(true);
|
this._loading.set(true);
|
||||||
try {
|
try {
|
||||||
const user = await firstValueFrom(
|
const user = await firstValueFrom(this.http.post<User>('/api/login', credentials));
|
||||||
this.http.post<User>("/api/login", credentials),
|
|
||||||
);
|
|
||||||
this._user.set(user);
|
this._user.set(user);
|
||||||
} finally {
|
} finally {
|
||||||
this._loading.set(false);
|
this._loading.set(false);
|
||||||
|
|||||||
@@ -103,13 +103,13 @@ interface ProductState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ProductSt {
|
export class ProductSt {
|
||||||
// Private state
|
// Private state
|
||||||
private state = signal<ProductState>({
|
private state = signal<ProductState>({
|
||||||
products: [],
|
products: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
filter: "",
|
filter: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -124,9 +124,7 @@ export class ProductSt {
|
|||||||
readonly filteredProducts = computed(() => {
|
readonly filteredProducts = computed(() => {
|
||||||
const { products, filter } = this.state();
|
const { products, filter } = this.state();
|
||||||
if (!filter) return products;
|
if (!filter) return products;
|
||||||
return products.filter((p) =>
|
return products.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()));
|
||||||
p.name.toLowerCase().includes(filter.toLowerCase()),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedProduct = computed(() => {
|
readonly selectedProduct = computed(() => {
|
||||||
@@ -149,23 +147,19 @@ export class ProductSt {
|
|||||||
this.state.update((s) => ({ ...s, loading: true, error: null }));
|
this.state.update((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const products = await firstValueFrom(
|
const products = await firstValueFrom(this.http.get<Product[]>('/api/products'));
|
||||||
this.http.get<Product[]>("/api/products"),
|
|
||||||
);
|
|
||||||
this.state.update((s) => ({ ...s, products, loading: false }));
|
this.state.update((s) => ({ ...s, products, loading: false }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.state.update((s) => ({
|
this.state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: "Failed to load products",
|
error: 'Failed to load products',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addProduct(product: Omit<Product, "id">): Promise<void> {
|
async addProduct(product: Omit<Product, 'id'>): Promise<void> {
|
||||||
const newProduct = await firstValueFrom(
|
const newProduct = await firstValueFrom(this.http.post<Product>('/api/products', product));
|
||||||
this.http.post<Product>("/api/products", product),
|
|
||||||
);
|
|
||||||
this.state.update((s) => ({
|
this.state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
products: [...s.products, newProduct],
|
products: [...s.products, newProduct],
|
||||||
@@ -291,7 +285,7 @@ export class Search {
|
|||||||
### Optimistic Updates
|
### Optimistic Updates
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class Todo {
|
export class Todo {
|
||||||
private todos = signal<Todo[]>([]);
|
private todos = signal<Todo[]>([]);
|
||||||
readonly items = this.todos.asReadonly();
|
readonly items = this.todos.asReadonly();
|
||||||
@@ -301,9 +295,7 @@ export class Todo {
|
|||||||
async toggleTodo(id: string): Promise<void> {
|
async toggleTodo(id: string): Promise<void> {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
const previousTodos = this.todos();
|
const previousTodos = this.todos();
|
||||||
this.todos.update((todos) =>
|
this.todos.update((todos) => todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
|
||||||
todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await firstValueFrom(this.http.patch(`/api/todos/${id}/toggle`, {}));
|
await firstValueFrom(this.http.patch(`/api/todos/${id}/toggle`, {}));
|
||||||
@@ -318,8 +310,8 @@ export class Todo {
|
|||||||
## Testing Signals
|
## Testing Signals
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
describe("Counter", () => {
|
describe('Counter', () => {
|
||||||
it("should increment count", () => {
|
it('should increment count', () => {
|
||||||
const component = new Counter();
|
const component = new Counter();
|
||||||
|
|
||||||
expect(component.count()).toBe(0);
|
expect(component.count()).toBe(0);
|
||||||
@@ -331,7 +323,7 @@ describe("Counter", () => {
|
|||||||
expect(component.count()).toBe(2);
|
expect(component.count()).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should compute doubled value", () => {
|
it('should compute doubled value', () => {
|
||||||
const component = new Counter();
|
const component = new Counter();
|
||||||
|
|
||||||
expect(component.doubled()).toBe(0);
|
expect(component.doubled()).toBe(0);
|
||||||
@@ -341,7 +333,7 @@ describe("Counter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ProductSt", () => {
|
describe('ProductSt', () => {
|
||||||
let store: ProductSt;
|
let store: ProductSt;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
@@ -354,24 +346,24 @@ describe("ProductSt", () => {
|
|||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter products", () => {
|
it('should filter products', () => {
|
||||||
// Set initial state
|
// Set initial state
|
||||||
store["state"].set({
|
store['state'].set({
|
||||||
products: [
|
products: [
|
||||||
{ id: "1", name: "Apple" },
|
{ id: '1', name: 'Apple' },
|
||||||
{ id: "2", name: "Banana" },
|
{ id: '2', name: 'Banana' },
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
filter: "",
|
filter: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.filteredProducts().length).toBe(2);
|
expect(store.filteredProducts().length).toBe(2);
|
||||||
|
|
||||||
store.setFilter("app");
|
store.setFilter('app');
|
||||||
expect(store.filteredProducts().length).toBe(1);
|
expect(store.filteredProducts().length).toBe(1);
|
||||||
expect(store.filteredProducts()[0].name).toBe("Apple");
|
expect(store.filteredProducts()[0].name).toBe('Apple');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -381,7 +373,7 @@ describe("ProductSt", () => {
|
|||||||
```typescript
|
```typescript
|
||||||
// Debug effect to log signal changes
|
// Debug effect to log signal changes
|
||||||
effect(() => {
|
effect(() => {
|
||||||
console.log("State changed:", {
|
console.log('State changed:', {
|
||||||
count: this.count(),
|
count: this.count(),
|
||||||
items: this.items(),
|
items: this.items(),
|
||||||
filter: this.filter(),
|
filter: this.filter(),
|
||||||
@@ -393,7 +385,7 @@ const DEBUG = signal(false);
|
|||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (untracked(() => DEBUG())) {
|
if (untracked(() => DEBUG())) {
|
||||||
console.log("Debug:", this.state());
|
console.log('Debug:', this.state());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ For Vitest migration from Jasmine and advanced configuration, see [references/vi
|
|||||||
## Basic Component Test
|
## Basic Component Test
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { Counter } from "./counter.component";
|
import { Counter } from './counter.component';
|
||||||
|
|
||||||
describe("Counter", () => {
|
describe('Counter', () => {
|
||||||
let component: Counter;
|
let component: Counter;
|
||||||
let fixture: ComponentFixture<Counter>;
|
let fixture: ComponentFixture<Counter>;
|
||||||
|
|
||||||
@@ -66,22 +66,22 @@ describe("Counter", () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create", () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should increment count", () => {
|
it('should increment count', () => {
|
||||||
expect(component.count()).toBe(0);
|
expect(component.count()).toBe(0);
|
||||||
component.increment();
|
component.increment();
|
||||||
expect(component.count()).toBe(1);
|
expect(component.count()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display count in template", () => {
|
it('should display count in template', () => {
|
||||||
component.count.set(5);
|
component.count.set(5);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const element = fixture.nativeElement.querySelector(".count");
|
const element = fixture.nativeElement.querySelector('.count');
|
||||||
expect(element.textContent).toContain("5");
|
expect(element.textContent).toContain('5');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -91,10 +91,10 @@ describe("Counter", () => {
|
|||||||
### Direct Signal Testing
|
### Direct Signal Testing
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { signal, computed } from "@angular/core";
|
import { signal, computed } from '@angular/core';
|
||||||
|
|
||||||
describe("Signal logic", () => {
|
describe('Signal logic', () => {
|
||||||
it("should update computed when signal changes", () => {
|
it('should update computed when signal changes', () => {
|
||||||
const count = signal(0);
|
const count = signal(0);
|
||||||
const doubled = computed(() => count() * 2);
|
const doubled = computed(() => count() * 2);
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ describe("Signal logic", () => {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-todo-list",
|
selector: 'app-todo-list',
|
||||||
template: `
|
template: `
|
||||||
<ul>
|
<ul>
|
||||||
@for (todo of filteredTodos(); track todo.id) {
|
@for (todo of filteredTodos(); track todo.id) {
|
||||||
@@ -125,14 +125,14 @@ describe("Signal logic", () => {
|
|||||||
})
|
})
|
||||||
export class TodoList {
|
export class TodoList {
|
||||||
todos = signal<Todo[]>([]);
|
todos = signal<Todo[]>([]);
|
||||||
filter = signal<"all" | "active" | "done">("all");
|
filter = signal<'all' | 'active' | 'done'>('all');
|
||||||
|
|
||||||
filteredTodos = computed(() => {
|
filteredTodos = computed(() => {
|
||||||
const todos = this.todos();
|
const todos = this.todos();
|
||||||
switch (this.filter()) {
|
switch (this.filter()) {
|
||||||
case "active":
|
case 'active':
|
||||||
return todos.filter((t) => !t.done);
|
return todos.filter((t) => !t.done);
|
||||||
case "done":
|
case 'done':
|
||||||
return todos.filter((t) => t.done);
|
return todos.filter((t) => t.done);
|
||||||
default:
|
default:
|
||||||
return todos;
|
return todos;
|
||||||
@@ -142,7 +142,7 @@ export class TodoList {
|
|||||||
remaining = computed(() => this.todos().filter((t) => !t.done).length);
|
remaining = computed(() => this.todos().filter((t) => !t.done).length);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TodoList", () => {
|
describe('TodoList', () => {
|
||||||
let component: TodoList;
|
let component: TodoList;
|
||||||
let fixture: ComponentFixture<TodoList>;
|
let fixture: ComponentFixture<TodoList>;
|
||||||
|
|
||||||
@@ -155,14 +155,14 @@ describe("TodoList", () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter active todos", () => {
|
it('should filter active todos', () => {
|
||||||
component.todos.set([
|
component.todos.set([
|
||||||
{ id: "1", text: "Task 1", done: false },
|
{ id: '1', text: 'Task 1', done: false },
|
||||||
{ id: "2", text: "Task 2", done: true },
|
{ id: '2', text: 'Task 2', done: true },
|
||||||
{ id: "3", text: "Task 3", done: false },
|
{ id: '3', text: 'Task 3', done: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
component.filter.set("active");
|
component.filter.set('active');
|
||||||
|
|
||||||
expect(component.filteredTodos().length).toBe(2);
|
expect(component.filteredTodos().length).toBe(2);
|
||||||
expect(component.remaining()).toBe(2);
|
expect(component.remaining()).toBe(2);
|
||||||
@@ -183,21 +183,21 @@ export class OnPushCmpt {
|
|||||||
data = input.required<{ name: string }>();
|
data = input.required<{ name: string }>();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("OnPushCmpt", () => {
|
describe('OnPushCmpt', () => {
|
||||||
it("should update when input signal changes", () => {
|
it('should update when input signal changes', () => {
|
||||||
const fixture = TestBed.createComponent(OnPushCmpt);
|
const fixture = TestBed.createComponent(OnPushCmpt);
|
||||||
|
|
||||||
// Set input using setInput (for signal inputs)
|
// Set input using setInput (for signal inputs)
|
||||||
fixture.componentRef.setInput("data", { name: "Initial" });
|
fixture.componentRef.setInput('data', { name: 'Initial' });
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain("Initial");
|
expect(fixture.nativeElement.textContent).toContain('Initial');
|
||||||
|
|
||||||
// Update input
|
// Update input
|
||||||
fixture.componentRef.setInput("data", { name: "Updated" });
|
fixture.componentRef.setInput('data', { name: 'Updated' });
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain("Updated");
|
expect(fixture.nativeElement.textContent).toContain('Updated');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -207,7 +207,7 @@ describe("OnPushCmpt", () => {
|
|||||||
### Basic Service Test
|
### Basic Service Test
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Injectable({ providedIn: "root" })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CounterService {
|
export class CounterService {
|
||||||
private _count = signal(0);
|
private _count = signal(0);
|
||||||
readonly count = this._count.asReadonly();
|
readonly count = this._count.asReadonly();
|
||||||
@@ -220,7 +220,7 @@ export class CounterService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("CounterService", () => {
|
describe('CounterService', () => {
|
||||||
let service: CounterService;
|
let service: CounterService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -228,7 +228,7 @@ describe("CounterService", () => {
|
|||||||
service = TestBed.inject(CounterService);
|
service = TestBed.inject(CounterService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should increment count", () => {
|
it('should increment count', () => {
|
||||||
expect(service.count()).toBe(0);
|
expect(service.count()).toBe(0);
|
||||||
service.increment();
|
service.increment();
|
||||||
expect(service.count()).toBe(1);
|
expect(service.count()).toBe(1);
|
||||||
@@ -239,13 +239,10 @@ describe("CounterService", () => {
|
|||||||
### Service with HTTP
|
### Service with HTTP
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
HttpTestingController,
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
provideHttpClientTesting,
|
|
||||||
} from "@angular/common/http/testing";
|
|
||||||
import { provideHttpClient } from "@angular/common/http";
|
|
||||||
|
|
||||||
describe("UserService", () => {
|
describe('UserService', () => {
|
||||||
let service: UserService;
|
let service: UserService;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
@@ -262,15 +259,15 @@ describe("UserService", () => {
|
|||||||
httpMock.verify(); // Verify no outstanding requests
|
httpMock.verify(); // Verify no outstanding requests
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fetch user by id", () => {
|
it('should fetch user by id', () => {
|
||||||
const mockUser = { id: "1", name: "Test User" };
|
const mockUser = { id: '1', name: 'Test User' };
|
||||||
|
|
||||||
service.getUser("1").subscribe((user) => {
|
service.getUser('1').subscribe((user) => {
|
||||||
expect(user).toEqual(mockUser);
|
expect(user).toEqual(mockUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
const req = httpMock.expectOne("/api/users/1");
|
const req = httpMock.expectOne('/api/users/1');
|
||||||
expect(req.request.method).toBe("GET");
|
expect(req.request.method).toBe('GET');
|
||||||
req.flush(mockUser);
|
req.flush(mockUser);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -281,9 +278,9 @@ describe("UserService", () => {
|
|||||||
### Using Vitest Mocks
|
### Using Vitest Mocks
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
describe("UserProfile", () => {
|
describe('UserProfile', () => {
|
||||||
const mockUserService = {
|
const mockUserService = {
|
||||||
getUser: vi.fn(),
|
getUser: vi.fn(),
|
||||||
updateUser: vi.fn(),
|
updateUser: vi.fn(),
|
||||||
@@ -292,7 +289,7 @@ describe("UserProfile", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockUserService.getUser.mockReturnValue(of({ id: "1", name: "Test" }));
|
mockUserService.getUser.mockReturnValue(of({ id: '1', name: 'Test' }));
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [UserProfile],
|
imports: [UserProfile],
|
||||||
@@ -300,11 +297,11 @@ describe("UserProfile", () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call getUser on init", () => {
|
it('should call getUser on init', () => {
|
||||||
const fixture = TestBed.createComponent(UserProfile);
|
const fixture = TestBed.createComponent(UserProfile);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(mockUserService.getUser).toHaveBeenCalledWith("1");
|
expect(mockUserService.getUser).toHaveBeenCalledWith('1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -326,15 +323,13 @@ beforeEach(async () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show content when authenticated", () => {
|
it('should show content when authenticated', () => {
|
||||||
mockAuth.user.set({ id: "1", name: "Test User" });
|
mockAuth.user.set({ id: '1', name: 'Test User' });
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(ProtectedPage);
|
const fixture = TestBed.createComponent(ProtectedPage);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(
|
expect(fixture.nativeElement.querySelector('.protected-content')).toBeTruthy();
|
||||||
fixture.nativeElement.querySelector(".protected-content"),
|
|
||||||
).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -342,7 +337,7 @@ it("should show content when authenticated", () => {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-item",
|
selector: 'app-item',
|
||||||
template: `<div (click)="select()">{{ item().name }}</div>`,
|
template: `<div (click)="select()">{{ item().name }}</div>`,
|
||||||
})
|
})
|
||||||
export class ItemCmpt {
|
export class ItemCmpt {
|
||||||
@@ -354,18 +349,18 @@ export class ItemCmpt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ItemCmpt", () => {
|
describe('ItemCmpt', () => {
|
||||||
it("should emit selected event on click", () => {
|
it('should emit selected event on click', () => {
|
||||||
const fixture = TestBed.createComponent(ItemCmpt);
|
const fixture = TestBed.createComponent(ItemCmpt);
|
||||||
const item: Item = { id: "1", name: "Test Item" };
|
const item: Item = { id: '1', name: 'Test Item' };
|
||||||
|
|
||||||
fixture.componentRef.setInput("item", item);
|
fixture.componentRef.setInput('item', item);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
let emittedItem: Item | undefined;
|
let emittedItem: Item | undefined;
|
||||||
fixture.componentInstance.selected.subscribe((i) => (emittedItem = i));
|
fixture.componentInstance.selected.subscribe((i) => (emittedItem = i));
|
||||||
|
|
||||||
fixture.nativeElement.querySelector("div").click();
|
fixture.nativeElement.querySelector('div').click();
|
||||||
|
|
||||||
expect(emittedItem).toEqual(item);
|
expect(emittedItem).toEqual(item);
|
||||||
});
|
});
|
||||||
@@ -377,13 +372,13 @@ describe("ItemCmpt", () => {
|
|||||||
### Using fakeAsync
|
### Using fakeAsync
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { fakeAsync, tick, flush } from "@angular/core/testing";
|
import { fakeAsync, tick, flush } from '@angular/core/testing';
|
||||||
|
|
||||||
it("should debounce search", fakeAsync(() => {
|
it('should debounce search', fakeAsync(() => {
|
||||||
const fixture = TestBed.createComponent(SearchCmpt);
|
const fixture = TestBed.createComponent(SearchCmpt);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
fixture.componentInstance.query.set("test");
|
fixture.componentInstance.query.set('test');
|
||||||
|
|
||||||
tick(300); // Advance time for debounce
|
tick(300); // Advance time for debounce
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -397,9 +392,9 @@ it("should debounce search", fakeAsync(() => {
|
|||||||
### Using waitForAsync
|
### Using waitForAsync
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { waitForAsync } from "@angular/core/testing";
|
import { waitForAsync } from '@angular/core/testing';
|
||||||
|
|
||||||
it("should load data", waitForAsync(() => {
|
it('should load data', waitForAsync(() => {
|
||||||
const fixture = TestBed.createComponent(DataCmpt);
|
const fixture = TestBed.createComponent(DataCmpt);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -423,11 +418,11 @@ it("should load data", waitForAsync(() => {
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class UserCmpt {
|
export class UserCmpt {
|
||||||
userId = signal("1");
|
userId = signal('1');
|
||||||
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
|
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("UserCmpt", () => {
|
describe('UserCmpt', () => {
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -439,17 +434,17 @@ describe("UserCmpt", () => {
|
|||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display user name after loading", () => {
|
it('should display user name after loading', () => {
|
||||||
const fixture = TestBed.createComponent(UserCmpt);
|
const fixture = TestBed.createComponent(UserCmpt);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain("Loading");
|
expect(fixture.nativeElement.textContent).toContain('Loading');
|
||||||
|
|
||||||
const req = httpMock.expectOne("/api/users/1");
|
const req = httpMock.expectOne('/api/users/1');
|
||||||
req.flush({ id: "1", name: "John Doe" });
|
req.flush({ id: '1', name: 'John Doe' });
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain("John Doe");
|
expect(fixture.nativeElement.textContent).toContain('John Doe');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,15 +15,15 @@
|
|||||||
### Snapshot Testing
|
### Snapshot Testing
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
describe("UserCard", () => {
|
describe('UserCard', () => {
|
||||||
it("should match snapshot", () => {
|
it('should match snapshot', () => {
|
||||||
const fixture = TestBed.createComponent(UserCard);
|
const fixture = TestBed.createComponent(UserCard);
|
||||||
fixture.componentRef.setInput("user", {
|
fixture.componentRef.setInput('user', {
|
||||||
id: "1",
|
id: '1',
|
||||||
name: "John",
|
name: 'John',
|
||||||
email: "john@example.com",
|
email: 'john@example.com',
|
||||||
});
|
});
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -35,14 +35,14 @@ describe("UserCard", () => {
|
|||||||
### Parameterized Tests
|
### Parameterized Tests
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
describe("Validator", () => {
|
describe('Validator', () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ input: "", expected: false },
|
{ input: '', expected: false },
|
||||||
{ input: "test", expected: false },
|
{ input: 'test', expected: false },
|
||||||
{ input: "test@example.com", expected: true },
|
{ input: 'test@example.com', expected: true },
|
||||||
{ input: "invalid@", expected: false },
|
{ input: 'invalid@', expected: false },
|
||||||
])('should validate email "$input" as $expected', ({ input, expected }) => {
|
])('should validate email "$input" as $expected', ({ input, expected }) => {
|
||||||
expect(isValidEmail(input)).toBe(expected);
|
expect(isValidEmail(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
@@ -52,9 +52,9 @@ describe("Validator", () => {
|
|||||||
### Testing with Fake Timers
|
### Testing with Fake Timers
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
describe("Debounced Search", () => {
|
describe('Debounced Search', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
@@ -63,11 +63,11 @@ describe("Debounced Search", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should debounce search input", async () => {
|
it('should debounce search input', async () => {
|
||||||
const fixture = TestBed.createComponent(Search);
|
const fixture = TestBed.createComponent(Search);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
fixture.componentInstance.query.set("test");
|
fixture.componentInstance.query.set('test');
|
||||||
|
|
||||||
// Search not called yet
|
// Search not called yet
|
||||||
expect(fixture.componentInstance.results()).toEqual([]);
|
expect(fixture.componentInstance.results()).toEqual([]);
|
||||||
@@ -85,24 +85,24 @@ describe("Debounced Search", () => {
|
|||||||
### Module Mocking
|
### Module Mocking
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
// Mock entire module
|
// Mock entire module
|
||||||
vi.mock("./analytics.service", () => ({
|
vi.mock('./analytics.service', () => ({
|
||||||
Analytics: class {
|
Analytics: class {
|
||||||
track = vi.fn();
|
track = vi.fn();
|
||||||
identify = vi.fn();
|
identify = vi.fn();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("with mocked analytics", () => {
|
describe('with mocked analytics', () => {
|
||||||
it("should track events", () => {
|
it('should track events', () => {
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
const fixture = TestBed.createComponent(Dashboard);
|
||||||
const analytics = TestBed.inject(Analytics);
|
const analytics = TestBed.inject(Analytics);
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(analytics.track).toHaveBeenCalledWith("dashboard_viewed");
|
expect(analytics.track).toHaveBeenCalledWith('dashboard_viewed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -110,17 +110,17 @@ describe("with mocked analytics", () => {
|
|||||||
### Testing Async/Await
|
### Testing Async/Await
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
describe("User", () => {
|
describe('User', () => {
|
||||||
it("should load user data", async () => {
|
it('should load user data', async () => {
|
||||||
const mockUser = { id: "1", name: "Test" };
|
const mockUser = { id: '1', name: 'Test' };
|
||||||
const httpMock = TestBed.inject(HttpTestingController);
|
const httpMock = TestBed.inject(HttpTestingController);
|
||||||
const service = TestBed.inject(User);
|
const service = TestBed.inject(User);
|
||||||
|
|
||||||
const userPromise = service.loadUser("1");
|
const userPromise = service.loadUser('1');
|
||||||
|
|
||||||
httpMock.expectOne("/api/users/1").flush(mockUser);
|
httpMock.expectOne('/api/users/1').flush(mockUser);
|
||||||
|
|
||||||
const user = await userPromise;
|
const user = await userPromise;
|
||||||
expect(user).toEqual(mockUser);
|
expect(user).toEqual(mockUser);
|
||||||
@@ -135,14 +135,9 @@ describe("User", () => {
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: 'v8',
|
||||||
reporter: ["text", "html", "lcov"],
|
reporter: ['text', 'html', 'lcov'],
|
||||||
exclude: [
|
exclude: ['node_modules/', 'src/test-setup.ts', '**/*.spec.ts', '**/*.d.ts'],
|
||||||
"node_modules/",
|
|
||||||
"src/test-setup.ts",
|
|
||||||
"**/*.spec.ts",
|
|
||||||
"**/*.d.ts",
|
|
||||||
],
|
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 80,
|
statements: 80,
|
||||||
branches: 80,
|
branches: 80,
|
||||||
@@ -167,19 +162,19 @@ npx vitest --ui --port 51204
|
|||||||
### Concurrent Tests
|
### Concurrent Tests
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
// Run tests in this describe block concurrently
|
// Run tests in this describe block concurrently
|
||||||
describe.concurrent("API calls", () => {
|
describe.concurrent('API calls', () => {
|
||||||
it("should fetch users", async () => {
|
it('should fetch users', async () => {
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fetch products", async () => {
|
it('should fetch products', async () => {
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fetch orders", async () => {
|
it('should fetch orders', async () => {
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -188,31 +183,28 @@ describe.concurrent("API calls", () => {
|
|||||||
### Test Fixtures
|
### Test Fixtures
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
// Shared test fixtures
|
// Shared test fixtures
|
||||||
const createTestUser = (overrides = {}) => ({
|
const createTestUser = (overrides = {}) => ({
|
||||||
id: "1",
|
id: '1',
|
||||||
name: "Test User",
|
name: 'Test User',
|
||||||
email: "test@example.com",
|
email: 'test@example.com',
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createTestProduct = (overrides = {}) => ({
|
const createTestProduct = (overrides = {}) => ({
|
||||||
id: "1",
|
id: '1',
|
||||||
name: "Test Product",
|
name: 'Test Product',
|
||||||
price: 99.99,
|
price: 99.99,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Order", () => {
|
describe('Order', () => {
|
||||||
it("should calculate total", () => {
|
it('should calculate total', () => {
|
||||||
const fixture = TestBed.createComponent(Order);
|
const fixture = TestBed.createComponent(Order);
|
||||||
fixture.componentRef.setInput("user", createTestUser());
|
fixture.componentRef.setInput('user', createTestUser());
|
||||||
fixture.componentRef.setInput("products", [
|
fixture.componentRef.setInput('products', [createTestProduct({ price: 10 }), createTestProduct({ id: '2', price: 20 })]);
|
||||||
createTestProduct({ price: 10 }),
|
|
||||||
createTestProduct({ id: "2", price: 20 }),
|
|
||||||
]);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.componentInstance.total()).toBe(30);
|
expect(fixture.componentInstance.total()).toBe(30);
|
||||||
@@ -227,15 +219,15 @@ Use Angular CDK component harnesses for more maintainable tests:
|
|||||||
### Creating a Harness
|
### Creating a Harness
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { ComponentHarness, HarnessPredicate } from "@angular/cdk/testing";
|
import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';
|
||||||
|
|
||||||
export class CounterHarn extends ComponentHarness {
|
export class CounterHarn extends ComponentHarness {
|
||||||
static hostSelector = "app-counter";
|
static hostSelector = 'app-counter';
|
||||||
|
|
||||||
// Locators
|
// Locators
|
||||||
private getIncrementButton = this.locatorFor("button.increment");
|
private getIncrementButton = this.locatorFor('button.increment');
|
||||||
private getDecrementButton = this.locatorFor("button.decrement");
|
private getDecrementButton = this.locatorFor('button.decrement');
|
||||||
private getCountDisplay = this.locatorFor(".count");
|
private getCountDisplay = this.locatorFor('.count');
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
async increment(): Promise<void> {
|
async increment(): Promise<void> {
|
||||||
@@ -257,13 +249,9 @@ export class CounterHarn extends ComponentHarness {
|
|||||||
|
|
||||||
// Filter factory
|
// Filter factory
|
||||||
static with(options: { count?: number } = {}): HarnessPredicate<CounterHarn> {
|
static with(options: { count?: number } = {}): HarnessPredicate<CounterHarn> {
|
||||||
return new HarnessPredicate(CounterHarn, options).addOption(
|
return new HarnessPredicate(CounterHarn, options).addOption('count', options.count, async (harness, count) => {
|
||||||
"count",
|
|
||||||
options.count,
|
|
||||||
async (harness, count) => {
|
|
||||||
return (await harness.getCount()) === count;
|
return (await harness.getCount()) === count;
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -271,9 +259,9 @@ export class CounterHarn extends ComponentHarness {
|
|||||||
### Using Harnesses in Tests
|
### Using Harnesses in Tests
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed";
|
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
|
||||||
|
|
||||||
describe("Counter with Harness", () => {
|
describe('Counter with Harness', () => {
|
||||||
let loader: HarnessLoader;
|
let loader: HarnessLoader;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -285,7 +273,7 @@ describe("Counter with Harness", () => {
|
|||||||
loader = TestbedHarnessEnvironment.loader(fixture);
|
loader = TestbedHarnessEnvironment.loader(fixture);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should increment count", async () => {
|
it('should increment count', async () => {
|
||||||
const counter = await loader.getHarness(CounterHarn);
|
const counter = await loader.getHarness(CounterHarn);
|
||||||
|
|
||||||
expect(await counter.getCount()).toBe(0);
|
expect(await counter.getCount()).toBe(0);
|
||||||
@@ -297,15 +285,13 @@ describe("Counter with Harness", () => {
|
|||||||
expect(await counter.getCount()).toBe(2);
|
expect(await counter.getCount()).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should find counter with specific count", async () => {
|
it('should find counter with specific count', async () => {
|
||||||
const counter = await loader.getHarness(CounterHarn);
|
const counter = await loader.getHarness(CounterHarn);
|
||||||
await counter.increment();
|
await counter.increment();
|
||||||
await counter.increment();
|
await counter.increment();
|
||||||
|
|
||||||
// Find counter with count of 2
|
// Find counter with count of 2
|
||||||
const counterWith2 = await loader.getHarness(
|
const counterWith2 = await loader.getHarness(CounterHarn.with({ count: 2 }));
|
||||||
CounterHarn.with({ count: 2 }),
|
|
||||||
);
|
|
||||||
expect(counterWith2).toBeTruthy();
|
expect(counterWith2).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -316,18 +302,18 @@ describe("Counter with Harness", () => {
|
|||||||
### RouterTestingHarness
|
### RouterTestingHarness
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { RouterTestingHarness } from "@angular/router/testing";
|
import { RouterTestingHarness } from '@angular/router/testing';
|
||||||
import { provideRouter } from "@angular/router";
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
describe("Router Navigation", () => {
|
describe('Router Navigation', () => {
|
||||||
let harness: RouterTestingHarness;
|
let harness: RouterTestingHarness;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([
|
provideRouter([
|
||||||
{ path: "", component: Home },
|
{ path: '', component: Home },
|
||||||
{ path: "users/:id", component: UserCmpt },
|
{ path: 'users/:id', component: UserCmpt },
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
@@ -335,16 +321,16 @@ describe("Router Navigation", () => {
|
|||||||
harness = await RouterTestingHarness.create();
|
harness = await RouterTestingHarness.create();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should navigate to user page", async () => {
|
it('should navigate to user page', async () => {
|
||||||
const component = await harness.navigateByUrl("/users/123", UserCmpt);
|
const component = await harness.navigateByUrl('/users/123', UserCmpt);
|
||||||
|
|
||||||
expect(component.id()).toBe("123");
|
expect(component.id()).toBe('123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display user name", async () => {
|
it('should display user name', async () => {
|
||||||
await harness.navigateByUrl("/users/123");
|
await harness.navigateByUrl('/users/123');
|
||||||
|
|
||||||
expect(harness.routeNativeElement?.textContent).toContain("User 123");
|
expect(harness.routeNativeElement?.textContent).toContain('User 123');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -352,19 +338,19 @@ describe("Router Navigation", () => {
|
|||||||
### Testing Guards
|
### Testing Guards
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
describe("AuthGuard", () => {
|
describe('AuthGuard', () => {
|
||||||
let authService: jasmine.SpyObj<Auth>;
|
let authService: jasmine.SpyObj<Auth>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authService = jasmine.createSpyObj("Auth", ["isAuthenticated"]);
|
authService = jasmine.createSpyObj('Auth', ['isAuthenticated']);
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: Auth, useValue: authService },
|
{ provide: Auth, useValue: authService },
|
||||||
provideRouter([
|
provideRouter([
|
||||||
{ path: "login", component: Login },
|
{ path: 'login', component: Login },
|
||||||
{
|
{
|
||||||
path: "dashboard",
|
path: 'dashboard',
|
||||||
component: Dashboard,
|
component: Dashboard,
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
},
|
},
|
||||||
@@ -373,22 +359,22 @@ describe("AuthGuard", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should allow access when authenticated", async () => {
|
it('should allow access when authenticated', async () => {
|
||||||
authService.isAuthenticated.and.returnValue(true);
|
authService.isAuthenticated.and.returnValue(true);
|
||||||
|
|
||||||
const harness = await RouterTestingHarness.create();
|
const harness = await RouterTestingHarness.create();
|
||||||
await harness.navigateByUrl("/dashboard");
|
await harness.navigateByUrl('/dashboard');
|
||||||
|
|
||||||
expect(harness.routeNativeElement?.textContent).toContain("Dashboard");
|
expect(harness.routeNativeElement?.textContent).toContain('Dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should redirect to login when not authenticated", async () => {
|
it('should redirect to login when not authenticated', async () => {
|
||||||
authService.isAuthenticated.and.returnValue(false);
|
authService.isAuthenticated.and.returnValue(false);
|
||||||
|
|
||||||
const harness = await RouterTestingHarness.create();
|
const harness = await RouterTestingHarness.create();
|
||||||
await harness.navigateByUrl("/dashboard");
|
await harness.navigateByUrl('/dashboard');
|
||||||
|
|
||||||
expect(TestBed.inject(Router).url).toBe("/login");
|
expect(TestBed.inject(Router).url).toBe('/login');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -398,20 +384,26 @@ describe("AuthGuard", () => {
|
|||||||
### Testing Signal Forms
|
### Testing Signal Forms
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { form, FormField, required, email } from "@angular/forms/signals";
|
import { form, FormField, required, email } from '@angular/forms/signals';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
imports: [FormField],
|
imports: [FormField],
|
||||||
template: `
|
template: `
|
||||||
<form (submit)="onSubmit($event)">
|
<form (submit)="onSubmit($event)">
|
||||||
<input [formField]="loginForm.email" />
|
<input [formField]="loginForm.email" />
|
||||||
<input [formField]="loginForm.password" type="password" />
|
<input
|
||||||
<button type="submit" [disabled]="loginForm().invalid()">Submit</button>
|
type="password"
|
||||||
|
[formField]="loginForm.password" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="loginForm().invalid()">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class Login {
|
export class Login {
|
||||||
model = signal({ email: "", password: "" });
|
model = signal({ email: '', password: '' });
|
||||||
loginForm = form(this.model, (schemaPath) => {
|
loginForm = form(this.model, (schemaPath) => {
|
||||||
required(schemaPath.email);
|
required(schemaPath.email);
|
||||||
email(schemaPath.email);
|
email(schemaPath.email);
|
||||||
@@ -428,7 +420,7 @@ export class Login {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Login", () => {
|
describe('Login', () => {
|
||||||
let fixture: ComponentFixture<Login>;
|
let fixture: ComponentFixture<Login>;
|
||||||
let component: Login;
|
let component: Login;
|
||||||
|
|
||||||
@@ -442,21 +434,21 @@ describe("Login", () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be invalid when empty", () => {
|
it('should be invalid when empty', () => {
|
||||||
expect(component.loginForm().invalid()).toBeTrue();
|
expect(component.loginForm().invalid()).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be valid with correct data", () => {
|
it('should be valid with correct data', () => {
|
||||||
component.model.set({
|
component.model.set({
|
||||||
email: "test@example.com",
|
email: 'test@example.com',
|
||||||
password: "password123",
|
password: 'password123',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(component.loginForm().valid()).toBeTrue();
|
expect(component.loginForm().valid()).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show email error for invalid email", () => {
|
it('should show email error for invalid email', () => {
|
||||||
component.loginForm.email().value.set("invalid");
|
component.loginForm.email().value.set('invalid');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.loginForm.email().invalid()).toBeTrue();
|
expect(component.loginForm.email().invalid()).toBeTrue();
|
||||||
@@ -464,12 +456,12 @@ describe("Login", () => {
|
|||||||
component.loginForm
|
component.loginForm
|
||||||
.email()
|
.email()
|
||||||
.errors()
|
.errors()
|
||||||
.some((e) => e.kind === "email"),
|
.some((e) => e.kind === 'email'),
|
||||||
).toBeTrue();
|
).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should disable submit button when invalid", () => {
|
it('should disable submit button when invalid', () => {
|
||||||
const button = fixture.nativeElement.querySelector("button");
|
const button = fixture.nativeElement.querySelector('button');
|
||||||
expect(button.disabled).toBeTrue();
|
expect(button.disabled).toBeTrue();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -478,32 +470,32 @@ describe("Login", () => {
|
|||||||
### Testing Reactive Forms
|
### Testing Reactive Forms
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
describe("ReactiveForm", () => {
|
describe('ReactiveForm', () => {
|
||||||
it("should validate form", () => {
|
it('should validate form', () => {
|
||||||
const fixture = TestBed.createComponent(ProfileForm);
|
const fixture = TestBed.createComponent(ProfileForm);
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
expect(component.form.valid).toBeFalse();
|
expect(component.form.valid).toBeFalse();
|
||||||
|
|
||||||
component.form.patchValue({
|
component.form.patchValue({
|
||||||
name: "John",
|
name: 'John',
|
||||||
email: "john@example.com",
|
email: 'john@example.com',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(component.form.valid).toBeTrue();
|
expect(component.form.valid).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show validation errors", () => {
|
it('should show validation errors', () => {
|
||||||
const fixture = TestBed.createComponent(ProfileForm);
|
const fixture = TestBed.createComponent(ProfileForm);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const emailControl = fixture.componentInstance.form.controls.email;
|
const emailControl = fixture.componentInstance.form.controls.email;
|
||||||
emailControl.setValue("invalid");
|
emailControl.setValue('invalid');
|
||||||
emailControl.markAsTouched();
|
emailControl.markAsTouched();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const errorElement = fixture.nativeElement.querySelector(".error");
|
const errorElement = fixture.nativeElement.querySelector('.error');
|
||||||
expect(errorElement.textContent).toContain("Invalid email");
|
expect(errorElement.textContent).toContain('Invalid email');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -514,28 +506,28 @@ describe("ReactiveForm", () => {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appHighlight]",
|
selector: '[appHighlight]',
|
||||||
host: {
|
host: {
|
||||||
"[style.backgroundColor]": "color()",
|
'[style.backgroundColor]': 'color()',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class Highlight {
|
export class Highlight {
|
||||||
color = input("yellow", { alias: "appHighlight" });
|
color = input('yellow', { alias: 'appHighlight' });
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Highlight", () => {
|
describe('Highlight', () => {
|
||||||
@Component({
|
@Component({
|
||||||
imports: [Highlight],
|
imports: [Highlight],
|
||||||
template: `<p appHighlight="lightblue">Test</p>`,
|
template: `<p appHighlight="lightblue">Test</p>`,
|
||||||
})
|
})
|
||||||
class Test {}
|
class Test {}
|
||||||
|
|
||||||
it("should apply background color", () => {
|
it('should apply background color', () => {
|
||||||
const fixture = TestBed.createComponent(Test);
|
const fixture = TestBed.createComponent(Test);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const p = fixture.nativeElement.querySelector("p");
|
const p = fixture.nativeElement.querySelector('p');
|
||||||
expect(p.style.backgroundColor).toBe("lightblue");
|
expect(p.style.backgroundColor).toBe('lightblue');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -544,13 +536,13 @@ describe("Highlight", () => {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: "[appIf]",
|
selector: '[appIf]',
|
||||||
})
|
})
|
||||||
export class If {
|
export class If {
|
||||||
private templateRef = inject(TemplateRef);
|
private templateRef = inject(TemplateRef);
|
||||||
private viewContainer = inject(ViewContainerRef);
|
private viewContainer = inject(ViewContainerRef);
|
||||||
|
|
||||||
condition = input.required<boolean>({ alias: "appIf" });
|
condition = input.required<boolean>({ alias: 'appIf' });
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -563,7 +555,7 @@ export class If {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("If", () => {
|
describe('If', () => {
|
||||||
@Component({
|
@Component({
|
||||||
imports: [If],
|
imports: [If],
|
||||||
template: `<p *appIf="show()">Visible</p>`,
|
template: `<p *appIf="show()">Visible</p>`,
|
||||||
@@ -572,16 +564,16 @@ describe("If", () => {
|
|||||||
show = signal(false);
|
show = signal(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
it("should show content when condition is true", () => {
|
it('should show content when condition is true', () => {
|
||||||
const fixture = TestBed.createComponent(Test);
|
const fixture = TestBed.createComponent(Test);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.nativeElement.querySelector("p")).toBeNull();
|
expect(fixture.nativeElement.querySelector('p')).toBeNull();
|
||||||
|
|
||||||
fixture.componentInstance.show.set(true);
|
fixture.componentInstance.show.set(true);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.nativeElement.querySelector("p")).toBeTruthy();
|
expect(fixture.nativeElement.querySelector('p')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -589,31 +581,31 @@ describe("If", () => {
|
|||||||
## Testing Pipes
|
## Testing Pipes
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@Pipe({ name: "truncate" })
|
@Pipe({ name: 'truncate' })
|
||||||
export class Truncate implements PipeTransform {
|
export class Truncate implements PipeTransform {
|
||||||
transform(value: string, length: number = 50): string {
|
transform(value: string, length: number = 50): string {
|
||||||
if (value.length <= length) return value;
|
if (value.length <= length) return value;
|
||||||
return value.substring(0, length) + "...";
|
return value.substring(0, length) + '...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Truncate", () => {
|
describe('Truncate', () => {
|
||||||
let pipe: Truncate;
|
let pipe: Truncate;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pipe = new Truncate();
|
pipe = new Truncate();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not truncate short strings", () => {
|
it('should not truncate short strings', () => {
|
||||||
expect(pipe.transform("Hello", 10)).toBe("Hello");
|
expect(pipe.transform('Hello', 10)).toBe('Hello');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should truncate long strings", () => {
|
it('should truncate long strings', () => {
|
||||||
expect(pipe.transform("Hello World", 5)).toBe("Hello...");
|
expect(pipe.transform('Hello World', 5)).toBe('Hello...');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default length", () => {
|
it('should use default length', () => {
|
||||||
const longString = "a".repeat(60);
|
const longString = 'a'.repeat(60);
|
||||||
const result = pipe.transform(longString);
|
const result = pipe.transform(longString);
|
||||||
expect(result.length).toBe(53); // 50 + '...'
|
expect(result.length).toBe(53); // 50 + '...'
|
||||||
});
|
});
|
||||||
@@ -626,22 +618,22 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// playwright.config.ts
|
// playwright.config.ts
|
||||||
import { defineConfig } from "@playwright/test";
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: './e2e',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: "html",
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://localhost:4200",
|
baseURL: 'http://localhost:4200',
|
||||||
trace: "on-first-retry",
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run start",
|
command: 'npm run start',
|
||||||
url: "http://localhost:4200",
|
url: 'http://localhost:4200',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -651,29 +643,29 @@ export default defineConfig({
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// e2e/login.spec.ts
|
// e2e/login.spec.ts
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe("Login", () => {
|
test.describe('Login', () => {
|
||||||
test("should login successfully", async ({ page }) => {
|
test('should login successfully', async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto('/login');
|
||||||
|
|
||||||
await page.fill('input[name="email"]', "test@example.com");
|
await page.fill('input[name="email"]', 'test@example.com');
|
||||||
await page.fill('input[name="password"]', "password123");
|
await page.fill('input[name="password"]', 'password123');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
await expect(page).toHaveURL("/dashboard");
|
await expect(page).toHaveURL('/dashboard');
|
||||||
await expect(page.locator("h1")).toContainText("Welcome");
|
await expect(page.locator('h1')).toContainText('Welcome');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show error for invalid credentials", async ({ page }) => {
|
test('should show error for invalid credentials', async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto('/login');
|
||||||
|
|
||||||
await page.fill('input[name="email"]', "wrong@example.com");
|
await page.fill('input[name="email"]', 'wrong@example.com');
|
||||||
await page.fill('input[name="password"]', "wrongpassword");
|
await page.fill('input[name="password"]', 'wrongpassword');
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
await expect(page.locator(".error")).toBeVisible();
|
await expect(page.locator('.error')).toBeVisible();
|
||||||
await expect(page.locator(".error")).toContainText("Invalid credentials");
|
await expect(page.locator('.error')).toContainText('Invalid credentials');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -684,31 +676,23 @@ test.describe("Login", () => {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// test-utils.ts
|
// test-utils.ts
|
||||||
export function setSignalInput<T>(
|
export function setSignalInput<T>(fixture: ComponentFixture<any>, inputName: string, value: T): void {
|
||||||
fixture: ComponentFixture<any>,
|
|
||||||
inputName: string,
|
|
||||||
value: T,
|
|
||||||
): void {
|
|
||||||
fixture.componentRef.setInput(inputName, value);
|
fixture.componentRef.setInput(inputName, value);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForSignal<T>(
|
export async function waitForSignal<T>(signal: () => T, predicate: (value: T) => boolean, timeout = 5000): Promise<T> {
|
||||||
signal: () => T,
|
|
||||||
predicate: (value: T) => boolean,
|
|
||||||
timeout = 5000,
|
|
||||||
): Promise<T> {
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeout) {
|
while (Date.now() - start < timeout) {
|
||||||
const value = signal();
|
const value = signal();
|
||||||
if (predicate(value)) return value;
|
if (predicate(value)) return value;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
}
|
}
|
||||||
throw new Error("Timeout waiting for signal");
|
throw new Error('Timeout waiting for signal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
it("should load data", async () => {
|
it('should load data', async () => {
|
||||||
const fixture = TestBed.createComponent(Data);
|
const fixture = TestBed.createComponent(Data);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
|||||||
@@ -17,49 +17,46 @@
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Jasmine
|
// Jasmine
|
||||||
const spy = jasmine.createSpy("callback");
|
const spy = jasmine.createSpy('callback');
|
||||||
spy.and.returnValue("value");
|
spy.and.returnValue('value');
|
||||||
expect(spy).toHaveBeenCalledWith("arg");
|
expect(spy).toHaveBeenCalledWith('arg');
|
||||||
|
|
||||||
// Vitest
|
// Vitest
|
||||||
const spy = vi.fn();
|
const spy = vi.fn();
|
||||||
spy.mockReturnValue("value");
|
spy.mockReturnValue('value');
|
||||||
expect(spy).toHaveBeenCalledWith("arg");
|
expect(spy).toHaveBeenCalledWith('arg');
|
||||||
```
|
```
|
||||||
|
|
||||||
### SpyOn Migration
|
### SpyOn Migration
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Jasmine
|
// Jasmine
|
||||||
spyOn(service, "method").and.returnValue(of(data));
|
spyOn(service, 'method').and.returnValue(of(data));
|
||||||
|
|
||||||
// Vitest
|
// Vitest
|
||||||
vi.spyOn(service, "method").mockReturnValue(of(data));
|
vi.spyOn(service, 'method').mockReturnValue(of(data));
|
||||||
```
|
```
|
||||||
|
|
||||||
### createSpyObj Migration
|
### createSpyObj Migration
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Jasmine
|
// Jasmine
|
||||||
const mockService = jasmine.createSpyObj("UserService", [
|
const mockService = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
|
||||||
"getUser",
|
mockService.getUser.and.returnValue(of({ id: '1', name: 'Test' }));
|
||||||
"updateUser",
|
|
||||||
]);
|
|
||||||
mockService.getUser.and.returnValue(of({ id: "1", name: "Test" }));
|
|
||||||
|
|
||||||
// Vitest
|
// Vitest
|
||||||
const mockService = {
|
const mockService = {
|
||||||
getUser: vi.fn(),
|
getUser: vi.fn(),
|
||||||
updateUser: vi.fn(),
|
updateUser: vi.fn(),
|
||||||
};
|
};
|
||||||
mockService.getUser.mockReturnValue(of({ id: "1", name: "Test" }));
|
mockService.getUser.mockReturnValue(of({ id: '1', name: 'Test' }));
|
||||||
```
|
```
|
||||||
|
|
||||||
### Async Testing Migration
|
### Async Testing Migration
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Jasmine - using done callback
|
// Jasmine - using done callback
|
||||||
it("should load data", (done) => {
|
it('should load data', (done) => {
|
||||||
service.loadData().subscribe((data) => {
|
service.loadData().subscribe((data) => {
|
||||||
expect(data).toBeDefined();
|
expect(data).toBeDefined();
|
||||||
done();
|
done();
|
||||||
@@ -67,7 +64,7 @@ it("should load data", (done) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Vitest - using async/await
|
// Vitest - using async/await
|
||||||
it("should load data", async () => {
|
it('should load data', async () => {
|
||||||
const data = await firstValueFrom(service.loadData());
|
const data = await firstValueFrom(service.loadData());
|
||||||
expect(data).toBeDefined();
|
expect(data).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -126,22 +123,17 @@ vi.useRealTimers();
|
|||||||
For advanced configuration, create a `vite.config.ts`:
|
For advanced configuration, create a `vite.config.ts`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: 'jsdom',
|
||||||
include: ["src/**/*.spec.ts"],
|
include: ['src/**/*.spec.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: 'v8',
|
||||||
reporter: ["text", "html", "lcov"],
|
reporter: ['text', 'html', 'lcov'],
|
||||||
exclude: [
|
exclude: ['node_modules/', 'src/test-setup.ts', '**/*.spec.ts', '**/*.d.ts'],
|
||||||
"node_modules/",
|
|
||||||
"src/test-setup.ts",
|
|
||||||
"**/*.spec.ts",
|
|
||||||
"**/*.d.ts",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -249,13 +249,13 @@ ng lint --fix
|
|||||||
// src/environments/environment.ts
|
// src/environments/environment.ts
|
||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiUrl: "http://localhost:3000/api",
|
apiUrl: 'http://localhost:3000/api',
|
||||||
};
|
};
|
||||||
|
|
||||||
// src/environments/environment.prod.ts
|
// src/environments/environment.prod.ts
|
||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiUrl: "https://api.example.com",
|
apiUrl: 'https://api.example.com',
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -25,21 +25,12 @@ schematics blank --name=my-schematics
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// src/my-component/index.ts
|
// src/my-component/index.ts
|
||||||
import {
|
import { Rule, SchematicContext, Tree, apply, url, template, move, mergeWith } from '@angular-devkit/schematics';
|
||||||
Rule,
|
import { strings } from '@angular-devkit/core';
|
||||||
SchematicContext,
|
|
||||||
Tree,
|
|
||||||
apply,
|
|
||||||
url,
|
|
||||||
template,
|
|
||||||
move,
|
|
||||||
mergeWith,
|
|
||||||
} from "@angular-devkit/schematics";
|
|
||||||
import { strings } from "@angular-devkit/core";
|
|
||||||
|
|
||||||
export function myComponent(options: { name: string; path: string }): Rule {
|
export function myComponent(options: { name: string; path: string }): Rule {
|
||||||
return (tree: Tree, context: SchematicContext) => {
|
return (tree: Tree, context: SchematicContext) => {
|
||||||
const templateSource = apply(url("./files"), [
|
const templateSource = apply(url('./files'), [
|
||||||
template({
|
template({
|
||||||
...options,
|
...options,
|
||||||
...strings,
|
...strings,
|
||||||
@@ -106,14 +97,12 @@ last 2 Edge versions
|
|||||||
// Lazy load routes for automatic code splitting
|
// Lazy load routes for automatic code splitting
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: "admin",
|
path: 'admin',
|
||||||
loadChildren: () =>
|
loadChildren: () => import('./admin/admin.routes').then((m) => m.adminRoutes),
|
||||||
import("./admin/admin.routes").then((m) => m.adminRoutes),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "reports",
|
path: 'reports',
|
||||||
loadComponent: () =>
|
loadComponent: () => import('./reports/reports.component').then((m) => m.Reports),
|
||||||
import("./reports/reports.component").then((m) => m.Reports),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
@@ -124,21 +113,17 @@ Ensure proper imports for tree shaking:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Good - tree shakeable
|
// Good - tree shakeable
|
||||||
import { map, filter } from "rxjs";
|
import { map, filter } from 'rxjs';
|
||||||
|
|
||||||
// Avoid - imports entire library
|
// Avoid - imports entire library
|
||||||
import * as rxjs from "rxjs";
|
import * as rxjs from 'rxjs';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Preload Strategy
|
### Preload Strategy
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// app.config.ts
|
// app.config.ts
|
||||||
import {
|
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
|
||||||
provideRouter,
|
|
||||||
withPreloading,
|
|
||||||
PreloadAllModules,
|
|
||||||
} from "@angular/router";
|
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [provideRouter(routes, withPreloading(PreloadAllModules))],
|
providers: [provideRouter(routes, withPreloading(PreloadAllModules))],
|
||||||
@@ -206,7 +191,7 @@ ng serve admin-app
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// After building library: ng build shared-ui
|
// After building library: ng build shared-ui
|
||||||
import { Button } from "shared-ui";
|
import { Button } from 'shared-ui';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
imports: [Button],
|
imports: [Button],
|
||||||
@@ -239,8 +224,8 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: '20'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -319,10 +304,10 @@ build:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Instead of relative imports
|
// Instead of relative imports
|
||||||
import { User } from "../../../core/services/user.service";
|
import { User } from '../../../core/services/user.service';
|
||||||
|
|
||||||
// Use path alias
|
// Use path alias
|
||||||
import { User } from "@core/services/user.service";
|
import { User } from '@core/services/user.service';
|
||||||
```
|
```
|
||||||
|
|
||||||
## Proxy Configuration
|
## Proxy Configuration
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSameLine": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"embeddedLanguageFormatting": "auto",
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"insertPragma": false,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"printWidth": 140,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"quoteProps": "consistent",
|
||||||
|
"requirePragma": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleAttributePerLine": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"useTabs": false,
|
||||||
|
"vueIndentScriptAndStyle": false,
|
||||||
|
"plugins": ["prettier-plugin-organize-attributes"],
|
||||||
|
"attributeGroups": ["$ANGULAR_STRUCTURAL_DIRECTIVE", "$DEFAULT", "$ANGULAR_INPUT", "$ANGULAR_TWO_WAY_BINDING", "$ANGULAR_OUTPUT"]
|
||||||
|
}
|
||||||
+18
-26
@@ -1,44 +1,36 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const eslint = require("@eslint/js");
|
const eslint = require('@eslint/js');
|
||||||
const { defineConfig } = require("eslint/config");
|
const { defineConfig } = require('eslint/config');
|
||||||
const tseslint = require("typescript-eslint");
|
const tseslint = require('typescript-eslint');
|
||||||
const angular = require("angular-eslint");
|
const angular = require('angular-eslint');
|
||||||
|
|
||||||
module.exports = defineConfig([
|
module.exports = defineConfig([
|
||||||
{
|
{
|
||||||
files: ["**/*.ts"],
|
files: ['**/*.ts'],
|
||||||
extends: [
|
extends: [eslint.configs.recommended, tseslint.configs.recommended, tseslint.configs.stylistic, angular.configs.tsRecommended],
|
||||||
eslint.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
tseslint.configs.stylistic,
|
|
||||||
angular.configs.tsRecommended,
|
|
||||||
],
|
|
||||||
processor: angular.processInlineTemplates,
|
processor: angular.processInlineTemplates,
|
||||||
rules: {
|
rules: {
|
||||||
"@angular-eslint/directive-selector": [
|
'@angular-eslint/directive-selector': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
type: "attribute",
|
type: 'attribute',
|
||||||
prefix: "app",
|
prefix: 'app',
|
||||||
style: "camelCase",
|
style: 'camelCase',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"@angular-eslint/component-selector": [
|
'@angular-eslint/component-selector': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
type: "element",
|
type: 'element',
|
||||||
prefix: "app",
|
prefix: 'app',
|
||||||
style: "kebab-case",
|
style: 'kebab-case',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ["**/*.html"],
|
files: ['**/*.html'],
|
||||||
extends: [
|
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
|
||||||
angular.configs.templateRecommended,
|
|
||||||
angular.configs.templateAccessibility,
|
|
||||||
],
|
|
||||||
rules: {},
|
rules: {},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
Generated
+39
@@ -22,6 +22,7 @@
|
|||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@angular-architects/ngrx-toolkit": "^21.0.1",
|
||||||
"@angular/build": "^21.2.1",
|
"@angular/build": "^21.2.1",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.2.1",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
|
"prettier-plugin-organize-attributes": "^1.0.0",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.56.1",
|
"typescript-eslint": "8.56.1",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
@@ -265,6 +267,28 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular-architects/ngrx-toolkit": {
|
||||||
|
"version": "21.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-architects/ngrx-toolkit/-/ngrx-toolkit-21.0.1.tgz",
|
||||||
|
"integrity": "sha512-mbwNxO+HIhf5ocUdgj7ywrSCpLLzgAg+whktDDhBoVQfLdlTZKoI6NRuOAjxTksaaok85Wv6+nHGBRuJLDIjsQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "^21.0.0",
|
||||||
|
"@angular/core": "^21.0.0",
|
||||||
|
"@ngrx/signals": "^21.0.0",
|
||||||
|
"@ngrx/store": "^21.0.0",
|
||||||
|
"rxjs": "^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@ngrx/store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular-devkit/architect": {
|
"node_modules/@angular-devkit/architect": {
|
||||||
"version": "0.2102.1",
|
"version": "0.2102.1",
|
||||||
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.1.tgz",
|
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.1.tgz",
|
||||||
@@ -3091,6 +3115,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-21.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-21.0.1.tgz",
|
||||||
"integrity": "sha512-krmZDhgHrnmZrxfEJ41bp/aM8Mc55k5B2N7oCLT5w4M3YbOkbnWPkP6bBWMv4XPI+2rqVgkLRW6DaWLwoESaBw==",
|
"integrity": "sha512-krmZDhgHrnmZrxfEJ41bp/aM8Mc55k5B2N7oCLT5w4M3YbOkbnWPkP6bBWMv4XPI+2rqVgkLRW6DaWLwoESaBw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -8507,6 +8532,7 @@
|
|||||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -8517,6 +8543,19 @@
|
|||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier-plugin-organize-attributes": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier-plugin-organize-attributes/-/prettier-plugin-organize-attributes-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-+NmameaLxbCcylEXsKPmawtzla5EE6ECqvGkpfQz4KM847fXDifB1gFnPQEpoADAq6IXg+cMI8Z0ISJEXa6fhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prettier": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proc-log": {
|
"node_modules/proc-log": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@angular-architects/ngrx-toolkit": "^21.0.1",
|
||||||
"@angular/build": "^21.2.1",
|
"@angular/build": "^21.2.1",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.2.1",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
|
"prettier-plugin-organize-attributes": "^1.0.0",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.56.1",
|
"typescript-eslint": "8.56.1",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
|
|||||||
+4
-16
@@ -1,19 +1,7 @@
|
|||||||
import {
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
ApplicationConfig,
|
import { provideRouter } from '@angular/router';
|
||||||
provideBrowserGlobalErrorListeners,
|
import { routes } from './app.routes';
|
||||||
isDevMode,
|
|
||||||
} from "@angular/core";
|
|
||||||
import { provideRouter } from "@angular/router";
|
|
||||||
|
|
||||||
import { routes } from "./app.routes";
|
|
||||||
import { provideStore } from "@ngrx/store";
|
|
||||||
import { provideStoreDevtools } from "@ngrx/store-devtools";
|
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
|
||||||
provideBrowserGlobalErrorListeners(),
|
|
||||||
provideRouter(routes),
|
|
||||||
provideStore(),
|
|
||||||
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
:host {
|
||||||
|
--page-ink: #13262f;
|
||||||
|
--page-muted: #5f7278;
|
||||||
|
--page-line: rgba(19, 38, 47, 0.1);
|
||||||
|
--page-panel: rgba(255, 255, 255, 0.74);
|
||||||
|
--page-panel-strong: rgba(255, 255, 255, 0.9);
|
||||||
|
--page-accent: #0f5d66;
|
||||||
|
--page-accent-strong: #0c3d49;
|
||||||
|
--page-glow: #d7f0eb;
|
||||||
|
display: block;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(255, 204, 112, 0.16), transparent 24%),
|
||||||
|
radial-gradient(circle at 85% 18%, rgba(76, 161, 175, 0.18), transparent 22%),
|
||||||
|
linear-gradient(180deg, #f4efe6, #edf2f0 38%, #f8fbfb);
|
||||||
|
color: var(--page-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(72rem, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.75rem 0 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 1.8rem;
|
||||||
|
padding: 1.35rem 1.5rem;
|
||||||
|
border: 1px solid var(--page-line);
|
||||||
|
border-radius: 1.6rem;
|
||||||
|
background: linear-gradient(180deg, var(--page-panel-strong), var(--page-panel));
|
||||||
|
box-shadow:
|
||||||
|
0 24px 60px rgba(29, 52, 58, 0.08),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--page-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, #d9edea, #f6d9b6);
|
||||||
|
color: var(--page-accent-strong);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--page-muted);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border: 1px solid rgba(15, 93, 102, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.76);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 10px 24px rgba(20, 38, 44, 0.06);
|
||||||
|
transition:
|
||||||
|
transform 180ms ease,
|
||||||
|
background-color 180ms ease,
|
||||||
|
color 180ms ease,
|
||||||
|
border-color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(15, 93, 102, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.active {
|
||||||
|
background: var(--page-accent-strong);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
main::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1rem auto auto -1rem;
|
||||||
|
width: 10rem;
|
||||||
|
height: 10rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, var(--page-glow), transparent 70%);
|
||||||
|
filter: blur(14px);
|
||||||
|
opacity: 0.65;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.shell {
|
||||||
|
width: min(72rem, calc(100% - 1rem));
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-header {
|
||||||
|
padding: 1.1rem 1rem;
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+27
-4
@@ -1,4 +1,27 @@
|
|||||||
<h2>Counter: {{ store.count() }}</h2>
|
<div class="shell">
|
||||||
<button (click)="store.increment()">Increment</button>
|
<header class="shell-header">
|
||||||
<button (click)="store.decrement()">Decrement</button>
|
<div class="brand-block">
|
||||||
<button (click)="store.reset()">Reset</button>
|
<p class="brand-kicker">Angular + NgRx Signals</p>
|
||||||
|
<div class="brand-row">
|
||||||
|
<h1>NGRX Playground</h1>
|
||||||
|
<span class="version-pill">Signal Store</span>
|
||||||
|
</div>
|
||||||
|
<p class="brand-copy">
|
||||||
|
Feature-first state architecture with persistence, derived state,
|
||||||
|
and production-ready debugging.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav aria-label="Primary">
|
||||||
|
<a
|
||||||
|
routerLink="/tasks"
|
||||||
|
routerLinkActive="active"
|
||||||
|
>Tasks</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|||||||
+16
-2
@@ -1,3 +1,17 @@
|
|||||||
import { Routes } from "@angular/router";
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
export const routes: Routes = [];
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'tasks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tasks',
|
||||||
|
loadComponent: () => import('./features/tasks/feature/tasks-page.component').then((module) => module.TasksPageComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: 'tasks',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
+9
-8
@@ -1,25 +1,26 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { App } from "./app";
|
import { appConfig } from './app.config';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
describe("App", () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
|
providers: appConfig.providers,
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create the app", () => {
|
it('should create the app', () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
expect(app).toBeTruthy();
|
expect(app).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render title", async () => {
|
it('should render title', async () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(compiled.querySelector("h1")?.textContent).toContain(
|
expect(compiled.querySelector('h1')?.textContent).toContain('NGRX Playground');
|
||||||
"Hello, ngrx-playground",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-8
@@ -1,11 +1,11 @@
|
|||||||
import { Component, inject } from "@angular/core";
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
import { CounterStore } from "./core/counter.store";
|
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-root",
|
selector: 'app-root',
|
||||||
templateUrl: "./app.html",
|
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||||
styleUrl: "./app.css",
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {}
|
||||||
readonly store = inject(CounterStore);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
|
|
||||||
|
|
||||||
// Define the shape of the state
|
|
||||||
export interface CounterState {
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the initial state
|
|
||||||
const initialState: CounterState = {
|
|
||||||
count: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the SignalStore
|
|
||||||
export const CounterStore = signalStore(
|
|
||||||
{ providedIn: "root" }, // Makes the store available app-wide
|
|
||||||
withState(initialState), // Adds the state to the store
|
|
||||||
|
|
||||||
// Adds methods to update the state
|
|
||||||
withMethods((store) => ({
|
|
||||||
increment() {
|
|
||||||
// Use patchState to immutably update the state
|
|
||||||
patchState(store, { count: store.count() + 1 });
|
|
||||||
},
|
|
||||||
decrement() {
|
|
||||||
patchState(store, { count: store.count() - 1 });
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
patchState(store, initialState);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export type TaskPriority = 'low' | 'medium' | 'high';
|
||||||
|
export type TaskFilter = 'all' | 'active' | 'completed';
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
completed: boolean;
|
||||||
|
priority: TaskPriority;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, delay, of, throwError } from 'rxjs';
|
||||||
|
import { Task, TaskPriority } from './task.model';
|
||||||
|
|
||||||
|
const NETWORK_DELAY_MS = 250;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TasksApiService {
|
||||||
|
private tasks: Task[] = [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Review signal store conventions',
|
||||||
|
completed: true,
|
||||||
|
priority: 'high',
|
||||||
|
updatedAt: '2026-03-08T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-2',
|
||||||
|
title: 'Document persistence boundaries',
|
||||||
|
completed: false,
|
||||||
|
priority: 'medium',
|
||||||
|
updatedAt: '2026-03-08T08:02:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-3',
|
||||||
|
title: 'Prepare lazy-loaded feature shell',
|
||||||
|
completed: false,
|
||||||
|
priority: 'low',
|
||||||
|
updatedAt: '2026-03-08T08:05:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
getTasks(): Observable<Task[]> {
|
||||||
|
return of(this.tasks).pipe(delay(NETWORK_DELAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
createTask(title: string, priority: TaskPriority): Observable<Task> {
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
|
||||||
|
if (!trimmedTitle) {
|
||||||
|
return throwError(() => new Error('Task title cannot be empty.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const task: Task = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: trimmedTitle,
|
||||||
|
completed: false,
|
||||||
|
priority,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tasks = [task, ...this.tasks];
|
||||||
|
|
||||||
|
return of(task).pipe(delay(NETWORK_DELAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTask(taskId: string): Observable<Task> {
|
||||||
|
const task = this.tasks.find((item) => item.id === taskId);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return throwError(() => new Error('Task not found.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTask: Task = {
|
||||||
|
...task,
|
||||||
|
completed: !task.completed,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tasks = this.tasks.map((item) => (item.id === taskId ? updatedTask : item));
|
||||||
|
|
||||||
|
return of(updatedTask).pipe(delay(NETWORK_DELAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveCompleted(): Observable<string[]> {
|
||||||
|
const archivedIds = this.tasks.filter((task) => task.completed).map((task) => task.id);
|
||||||
|
|
||||||
|
this.tasks = this.tasks.filter((task) => !task.completed);
|
||||||
|
|
||||||
|
return of(archivedIds).pipe(delay(NETWORK_DELAY_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { computed, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
addEntity,
|
||||||
|
removeEntities,
|
||||||
|
setAllEntities,
|
||||||
|
updateEntity,
|
||||||
|
withEntities,
|
||||||
|
} from '@ngrx/signals/entities';
|
||||||
|
import {
|
||||||
|
signalStore,
|
||||||
|
withComputed,
|
||||||
|
withHooks,
|
||||||
|
withMethods,
|
||||||
|
withState,
|
||||||
|
} from '@ngrx/signals';
|
||||||
|
import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
||||||
|
import {
|
||||||
|
setError,
|
||||||
|
setLoaded,
|
||||||
|
setLoading,
|
||||||
|
updateState,
|
||||||
|
withCallState,
|
||||||
|
withDevtools,
|
||||||
|
withLocalStorage,
|
||||||
|
withStorageSync,
|
||||||
|
} from '@angular-architects/ngrx-toolkit';
|
||||||
|
import { EMPTY, pipe } from 'rxjs';
|
||||||
|
import { catchError, switchMap, tap } from 'rxjs/operators';
|
||||||
|
import { Task, TaskFilter, TaskPriority } from './task.model';
|
||||||
|
import { TasksApiService } from './tasks-api.service';
|
||||||
|
|
||||||
|
interface TasksState {
|
||||||
|
filter: TaskFilter;
|
||||||
|
draftPriority: TaskPriority;
|
||||||
|
searchTerm: string;
|
||||||
|
lastSyncedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TasksState = {
|
||||||
|
filter: 'all',
|
||||||
|
draftPriority: 'medium',
|
||||||
|
searchTerm: '',
|
||||||
|
lastSyncedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function matchesFilter(task: Task, filter: TaskFilter): boolean {
|
||||||
|
switch (filter) {
|
||||||
|
case 'active':
|
||||||
|
return !task.completed;
|
||||||
|
case 'completed':
|
||||||
|
return task.completed;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TasksStore = signalStore(
|
||||||
|
{ providedIn: 'root' },
|
||||||
|
withState(initialState),
|
||||||
|
withEntities<Task>(),
|
||||||
|
withCallState(),
|
||||||
|
withDevtools('tasks-store'),
|
||||||
|
withStorageSync(
|
||||||
|
{
|
||||||
|
key: 'ngrx-playground.tasks',
|
||||||
|
select: (state) => ({
|
||||||
|
ids: state.ids,
|
||||||
|
entityMap: state.entityMap,
|
||||||
|
filter: state.filter,
|
||||||
|
draftPriority: state.draftPriority,
|
||||||
|
searchTerm: state.searchTerm,
|
||||||
|
lastSyncedAt: state.lastSyncedAt,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
withLocalStorage(),
|
||||||
|
),
|
||||||
|
withComputed((store) => ({
|
||||||
|
filteredTasks: computed(() => {
|
||||||
|
const normalizedSearch = store.searchTerm().trim().toLowerCase();
|
||||||
|
|
||||||
|
return store.entities().filter((task) => {
|
||||||
|
const matchesText = normalizedSearch.length === 0 || task.title.toLowerCase().includes(normalizedSearch);
|
||||||
|
|
||||||
|
return matchesFilter(task, store.filter()) && matchesText;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
totalCount: computed(() => store.entities().length),
|
||||||
|
activeCount: computed(() => store.entities().filter((task) => !task.completed).length),
|
||||||
|
completedCount: computed(() => store.entities().filter((task) => task.completed).length),
|
||||||
|
hasCompletedTasks: computed(() => store.entities().some((task) => task.completed)),
|
||||||
|
emptyStateMessage: computed(() => {
|
||||||
|
if (store.loading()) {
|
||||||
|
return 'Loading tasks...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.error()) {
|
||||||
|
return 'The task list could not be refreshed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.entities().length === 0) {
|
||||||
|
return 'No tasks saved yet. Create the first one.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'No tasks match the current filters.';
|
||||||
|
}),
|
||||||
|
lastSyncedLabel: computed(() => {
|
||||||
|
const value = store.lastSyncedAt();
|
||||||
|
return value ? new Date(value).toLocaleTimeString('en-US') : 'Never';
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
methodsStore(),
|
||||||
|
withHooks({
|
||||||
|
onInit(store) {
|
||||||
|
if (store.entities().length === 0) {
|
||||||
|
store.loadTasks();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function methodsStore() {
|
||||||
|
return withMethods((store, api = inject(TasksApiService)) => ({
|
||||||
|
loadTasks: rxMethod<void>(
|
||||||
|
pipe(
|
||||||
|
tap(() => updateState(store, 'tasks load started', setLoading())),
|
||||||
|
switchMap(() =>
|
||||||
|
api.getTasks().pipe(
|
||||||
|
tap((tasks) =>
|
||||||
|
updateState(
|
||||||
|
store,
|
||||||
|
'tasks load succeeded',
|
||||||
|
setAllEntities(tasks),
|
||||||
|
{ lastSyncedAt: new Date().toISOString() },
|
||||||
|
setLoaded(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
updateState(store, 'tasks load failed', setError(error));
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
createTask: rxMethod<{ title: string; priority: TaskPriority }>(
|
||||||
|
pipe(
|
||||||
|
tap(() => updateState(store, 'task create started', setLoading())),
|
||||||
|
switchMap(({ title, priority }) =>
|
||||||
|
api.createTask(title, priority).pipe(
|
||||||
|
tap((task) =>
|
||||||
|
updateState(store, 'task create succeeded', addEntity(task), { lastSyncedAt: task.updatedAt }, setLoaded()),
|
||||||
|
),
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
updateState(store, 'task create failed', setError(error));
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
toggleTask: rxMethod<string>(
|
||||||
|
pipe(
|
||||||
|
tap(() => updateState(store, 'task toggle started', setLoading())),
|
||||||
|
switchMap((taskId) =>
|
||||||
|
api.toggleTask(taskId).pipe(
|
||||||
|
tap((task) =>
|
||||||
|
updateState(
|
||||||
|
store,
|
||||||
|
'task toggle succeeded',
|
||||||
|
updateEntity({ id: task.id, changes: task }),
|
||||||
|
{ lastSyncedAt: task.updatedAt },
|
||||||
|
setLoaded(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
updateState(store, 'task toggle failed', setError(error));
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
archiveCompleted: rxMethod<void>(
|
||||||
|
pipe(
|
||||||
|
tap(() => updateState(store, 'completed tasks archive started', setLoading())),
|
||||||
|
switchMap(() =>
|
||||||
|
api.archiveCompleted().pipe(
|
||||||
|
tap((archivedIds) =>
|
||||||
|
updateState(
|
||||||
|
store,
|
||||||
|
'completed tasks archive succeeded',
|
||||||
|
removeEntities(archivedIds),
|
||||||
|
{ lastSyncedAt: new Date().toISOString() },
|
||||||
|
setLoaded(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
updateState(store, 'completed tasks archive failed', setError(error));
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
setFilter(filter: TaskFilter): void {
|
||||||
|
updateState(store, 'task filter changed', { filter });
|
||||||
|
},
|
||||||
|
|
||||||
|
setDraftPriority(priority: TaskPriority): void {
|
||||||
|
updateState(store, 'task priority draft changed', { draftPriority: priority });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSearchTerm(searchTerm: string): void {
|
||||||
|
updateState(store, 'task search changed', { searchTerm });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid rgba(214, 230, 230, 0.32);
|
||||||
|
border-radius: 1.8rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(120deg, rgba(255, 255, 255, 0.08), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgba(255, 196, 93, 0.34), transparent 28%),
|
||||||
|
linear-gradient(135deg, #102d3a, #11495b 56%, #2f7a72);
|
||||||
|
color: #f6fbff;
|
||||||
|
box-shadow:
|
||||||
|
0 34px 70px rgba(19, 43, 49, 0.18),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(246, 251, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1,
|
||||||
|
.hero .lead {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
max-width: 44rem;
|
||||||
|
color: rgba(246, 251, 255, 0.84);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid div {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 1.15rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(246, 251, 255, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid dd {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer,
|
||||||
|
.toolbar,
|
||||||
|
.task-card,
|
||||||
|
.empty-state {
|
||||||
|
padding: 1.2rem;
|
||||||
|
border: 1px solid rgba(20, 54, 60, 0.08);
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
box-shadow:
|
||||||
|
0 18px 40px rgba(18, 43, 48, 0.08),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer,
|
||||||
|
.toolbar {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2,
|
||||||
|
.section-note,
|
||||||
|
.section-kicker {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-kicker {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #5e787d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-note {
|
||||||
|
max-width: 26rem;
|
||||||
|
color: #678086;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-grid,
|
||||||
|
.toolbar-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer label,
|
||||||
|
.search {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer span,
|
||||||
|
.search span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #35535b;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.86rem 0.95rem;
|
||||||
|
border: 1px solid rgba(35, 71, 78, 0.18);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: rgba(249, 251, 251, 0.92);
|
||||||
|
color: #102932;
|
||||||
|
transition:
|
||||||
|
border-color 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
background-color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(17, 73, 91, 0.46);
|
||||||
|
box-shadow: 0 0 0 4px rgba(17, 73, 91, 0.12);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.78rem 1.08rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #153f49;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
transition:
|
||||||
|
transform 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
opacity 180ms ease,
|
||||||
|
background-color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not([disabled]) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 24px rgba(21, 63, 73, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-action {
|
||||||
|
background: linear-gradient(135deg, #153f49, #23666b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-action {
|
||||||
|
background: rgba(20, 54, 60, 0.08);
|
||||||
|
color: #153f49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters,
|
||||||
|
.toolbar-actions,
|
||||||
|
.task-main,
|
||||||
|
.task-status,
|
||||||
|
.task-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters button {
|
||||||
|
background: rgba(20, 54, 60, 0.08);
|
||||||
|
color: #15353a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters button.active {
|
||||||
|
background: #153f49;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
min-height: 3.1rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(19, 63, 73, 0),
|
||||||
|
rgba(19, 63, 73, 0.18),
|
||||||
|
rgba(19, 63, 73, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card.done {
|
||||||
|
background: rgba(238, 247, 244, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-main {
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-copy {
|
||||||
|
flex: 1 1 18rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-main h2,
|
||||||
|
.feedback {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-main h2 {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
gap: 0.55rem;
|
||||||
|
color: #5e767c;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(20, 54, 60, 0.08);
|
||||||
|
color: #1b4a51;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-mark {
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e2b34f;
|
||||||
|
box-shadow: 0 0 0 5px rgba(226, 179, 79, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-mark.high {
|
||||||
|
background: #cf5b35;
|
||||||
|
box-shadow: 0 0 0 5px rgba(207, 91, 53, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-mark.medium {
|
||||||
|
background: #d7a847;
|
||||||
|
box-shadow: 0 0 0 5px rgba(215, 168, 71, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-mark.low {
|
||||||
|
background: #4f8b74;
|
||||||
|
box-shadow: 0 0 0 5px rgba(79, 139, 116, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
min-width: 7.5rem;
|
||||||
|
background: rgba(217, 93, 57, 0.12);
|
||||||
|
color: #a94a2b;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card.done .toggle {
|
||||||
|
background: rgba(74, 130, 85, 0.14);
|
||||||
|
color: #406c45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(233, 243, 245, 0.86);
|
||||||
|
color: #24434c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback.error {
|
||||||
|
background: rgba(255, 233, 228, 0.9);
|
||||||
|
color: #8d2d10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #4a6368;
|
||||||
|
text-align: center;
|
||||||
|
padding-block: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.composer-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(10rem, 12rem) auto;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-grid {
|
||||||
|
grid-template-columns: minmax(16rem, 1.3fr) auto 1px auto;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 719px) {
|
||||||
|
.hero,
|
||||||
|
.composer,
|
||||||
|
.toolbar,
|
||||||
|
.task-card,
|
||||||
|
.empty-state {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-main {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
border-top: 1px solid rgba(19, 63, 73, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions button {
|
||||||
|
flex: 1 1 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-grid > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<section class="tasks-page">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<div class="hero-badges">
|
||||||
|
<p class="eyebrow">Signal Store Feature</p>
|
||||||
|
<span class="hero-chip">Devtools Connected</span>
|
||||||
|
</div>
|
||||||
|
<h1>Delivery Board</h1>
|
||||||
|
<p class="lead">
|
||||||
|
A realistic feature slice with entity state, asynchronous updates,
|
||||||
|
persistence, and a clean operator flow for scalable Angular
|
||||||
|
teams.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="summary-grid">
|
||||||
|
<div>
|
||||||
|
<dt>Total</dt>
|
||||||
|
<dd>{{ store.totalCount() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Active</dt>
|
||||||
|
<dd>{{ store.activeCount() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Completed</dt>
|
||||||
|
<dd>{{ store.completedCount() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Synced</dt>
|
||||||
|
<dd>{{ store.lastSyncedLabel() }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<form
|
||||||
|
class="composer"
|
||||||
|
(submit)="createTask(); $event.preventDefault()">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">Compose</p>
|
||||||
|
<h2>Capture the next delivery item</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-note">
|
||||||
|
Fast input for planning work without leaving the board.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-grid">
|
||||||
|
<label>
|
||||||
|
<span>Task title</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a task with product value"
|
||||||
|
[value]="draftTitle()"
|
||||||
|
(input)="updateDraftTitle($event)" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Priority</span>
|
||||||
|
<select
|
||||||
|
[value]="store.draftPriority()"
|
||||||
|
(change)="updateDraftPriority($event)">
|
||||||
|
@for (priority of priorities; track priority) {
|
||||||
|
<option [value]="priority">{{ priority }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="primary-action"
|
||||||
|
[disabled]="!canCreateTask()">
|
||||||
|
Create task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">Refine</p>
|
||||||
|
<h2>Focus the board</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-note">
|
||||||
|
Search, segment, refresh, or archive completed work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-grid">
|
||||||
|
<label class="search">
|
||||||
|
<span>Search</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter tasks"
|
||||||
|
[value]="store.searchTerm()"
|
||||||
|
(input)="updateSearchTerm($event)" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="filters"
|
||||||
|
role="group"
|
||||||
|
aria-label="Task filters">
|
||||||
|
@for (filter of filters; track filter) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[class.active]="store.filter() === filter"
|
||||||
|
(click)="store.setFilter(filter)">
|
||||||
|
{{ filter }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="toolbar-divider"
|
||||||
|
aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="toolbar-actions"
|
||||||
|
role="group"
|
||||||
|
aria-label="Task actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost-action"
|
||||||
|
(click)="store.loadTasks()">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary-action"
|
||||||
|
[disabled]="!store.hasCompletedTasks() || store.loading()"
|
||||||
|
(click)="store.archiveCompleted()">
|
||||||
|
Archive completed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (store.error(); as errorMessage) {
|
||||||
|
<p
|
||||||
|
class="feedback error"
|
||||||
|
role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (store.loading()) {
|
||||||
|
<p class="feedback">Synchronizing tasks...</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ul class="task-list">
|
||||||
|
@for (task of store.filteredTasks(); track task.id) {
|
||||||
|
<li
|
||||||
|
class="task-card"
|
||||||
|
[class.done]="task.completed">
|
||||||
|
<div class="task-main">
|
||||||
|
<div class="task-status">
|
||||||
|
<span
|
||||||
|
class="priority-mark"
|
||||||
|
[class.high]="task.priority === 'high'"
|
||||||
|
[class.medium]="task.priority === 'medium'"
|
||||||
|
[class.low]="task.priority === 'low'"></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle"
|
||||||
|
[attr.aria-pressed]="task.completed"
|
||||||
|
(click)="store.toggleTask(task.id)">
|
||||||
|
{{ task.completed ? 'Completed' : 'Open' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-copy">
|
||||||
|
<h2>{{ task.title }}</h2>
|
||||||
|
<div class="task-meta">
|
||||||
|
<span class="meta-pill">{{ task.priority }}</span>
|
||||||
|
<span>Updated {{ task.updatedAt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="empty-state">{{ store.emptyStateMessage() }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { TasksStore } from '../data-access/tasks.store';
|
||||||
|
import { TaskFilter, TaskPriority } from '../data-access/task.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tasks-page',
|
||||||
|
templateUrl: './tasks-page.component.html',
|
||||||
|
styleUrl: './tasks-page.component.css',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class TasksPageComponent {
|
||||||
|
readonly store = inject(TasksStore);
|
||||||
|
readonly draftTitle = signal('');
|
||||||
|
readonly canCreateTask = computed(() => this.draftTitle().trim().length > 0 && !this.store.loading());
|
||||||
|
readonly filters: TaskFilter[] = ['all', 'active', 'completed'];
|
||||||
|
readonly priorities: TaskPriority[] = ['low', 'medium', 'high'];
|
||||||
|
|
||||||
|
createTask(): void {
|
||||||
|
const title = this.draftTitle().trim();
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.createTask({
|
||||||
|
title,
|
||||||
|
priority: this.store.draftPriority(),
|
||||||
|
});
|
||||||
|
this.draftTitle.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDraftTitle(event: Event): void {
|
||||||
|
const element = event.target as HTMLInputElement;
|
||||||
|
this.draftTitle.set(element.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSearchTerm(event: Event): void {
|
||||||
|
const element = event.target as HTMLInputElement;
|
||||||
|
this.store.setSearchTerm(element.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDraftPriority(event: Event): void {
|
||||||
|
const element = event.target as HTMLSelectElement;
|
||||||
|
this.store.setDraftPriority(element.value as TaskPriority);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-2
@@ -4,8 +4,13 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>NgrxPlayground</title>
|
<title>NgrxPlayground</title>
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/x-icon"
|
||||||
|
href="favicon.ico" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { bootstrapApplication } from "@angular/platform-browser";
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from "./app/app.config";
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from "./app/app";
|
import { App } from './app/app';
|
||||||
|
|
||||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||||
|
|||||||
Reference in New Issue
Block a user