| name | angular-patterns |
| description | Angular frontend project conventions. Use when generating or reviewing Angular code for signals, standalone components, strong typing, one-class-per-file, enums/constants, routing, services, and i18n. |
Skill : angular-patterns — Conventions Angular 19 du projet
Chargé automatiquement par l'agent angular-front.
Contient les patterns techniques Angular 19 spécifiques au projet InfraFlowSculptor.
Règles Angular 19 — Fondamentaux
1. Standalone components obligatoires
Tous les composants sont standalone: true. Aucun NgModule n'est utilisé dans ce projet.
@Component({
selector: 'app-feature',
standalone: true,
imports: [CommonModule, MatButtonModule, RouterLink, ],
templateUrl: './feature.component.html',
styleUrl: './feature.component.scss',
})
export class FeatureComponent { }
2. Zoneless Change Detection
Le projet utilise provideExperimentalZonelessChangeDetection() dans app.config.ts. Cela signifie :
- Zone.js ne détecte plus les changements automatiquement.
- Utiliser exclusivement des Signals pour l'état local des composants.
- Utiliser
toSignal() pour convertir les Observables RxJS en Signals.
- Ne jamais appeler
ChangeDetectorRef.detectChanges() manuellement.
- Les Promises natives + Signals fonctionnent correctement.
3. Injection via inject() — jamais le constructeur
export class MyComponent {
private readonly router = inject(Router);
private readonly myService = inject(MyService);
}
export class MyComponent {
constructor(private router: Router) {}
}
Exception : les classes non-composants (guards en fonction, etc.) utilisent aussi inject().
4. Visibilité des membres dans les composants
Appliquer systématiquement les bons modificateurs de visibilité :
export class MyComponent {
private readonly router = inject(Router);
private readonly service = inject(MyService);
protected items = signal<Item[]>([]);
protected isLoading = signal(false);
protected onSubmit(): void { }
private loadData(): void { }
}
5. Typage fort et littéraux partagés
- Les composants, services, et facades exposent des modèles TypeScript explicites ; éviter
any, unknown, Record<string, unknown>, et les objets faibles si le contrat est connu.
- Les interfaces frontend doivent coller aux DTOs backend, ou à un view model local clairement nommé quand une adaptation UI est nécessaire.
- Les statuts, actions, modes, et autres littéraux métier répétés doivent être extraits dans des
enum, as const, ou constantes exportées dédiées plutôt que dispersés dans le code.
- Une seule classe Angular top-level par fichier. Les types auxiliaires réutilisables vivent dans des fichiers dédiés (
shared/interfaces, shared/enums, ou fichier local ciblé), pas dans un fourre-tout.
- Si une API externe renvoie une forme dynamique, la conversion vers un modèle typé doit être faite dans le service d'accès, pas dans le composant.
Signals API — Règles d'utilisation
Signals de base
import { signal, computed, effect, Signal } from '@angular/core';
protected count = signal(0);
protected items = signal<Item[]>([]);
protected isLoading = signal(false);
protected errorMessage = signal('');
protected total = computed(() => this.items().length);
protected hasError = computed(() => this.errorMessage() !== '');
private logEffect = effect(() => {
console.log('items changed:', this.items());
});
Input Signals (Angular 17.1+)
import { input, InputSignal } from '@angular/core';
name = input.required<string>();
maxItems = input(10);
userId = input.required<string>({ alias: 'id' });
Output (Angular 17.3+)
import { output, OutputEmitterRef } from '@angular/core';
itemSelected = output<Item>();
closed = output<void>();
this.itemSelected.emit(item);
this.closed.emit();
Model Signals (two-way binding)
import { model, ModelSignal } from '@angular/core';
value = model<string>('');
toSignal — conversion RxJS → Signal
import { toSignal } from '@angular/core/rxjs-interop';
protected currentRoute = toSignal(
this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
map(e => (e as NavigationEnd).url)
)
);
protected isAuthenticated = toSignal(
this.authService.isAuthenticated$,
{ initialValue: false }
);
resource() — chargement asynchrone réactif (Angular 19)
import { resource } from '@angular/core';
protected itemId = signal<string | null>(null);
protected itemResource = resource({
request: () => this.itemId(),
loader: async ({ request: id }) => {
if (!id) return null;
return this.myService.getById(id);
}
});
Template — Nouvelles syntaxes obligatoires
Utiliser exclusivement la nouvelle syntaxe de flux de contrôle Angular 17+ :
@if (isLoading()) {
<mat-spinner />
} @else if (hasError()) {
<p class="error">{{ errorMessage() }}</p>
} @else {
<p>Contenu chargé</p>
}
@for (item of items(); track item.id) {
<app-item [name]="item.name" />
} @empty {
<p>Aucun élément.</p>
}
@switch (status()) {
@case ('active') { <span class="badge-green">Actif</span> }
@case ('inactive') { <span class="badge-red">Inactif</span> }
@default { <span>Inconnu</span> }
}
<div *ngIf="isLoading">...</div>
<li *ngFor="let item of items">...</li>
track est obligatoire dans @for. Utiliser l'identifiant unique de l'objet (.id, .uuid, etc.), ou $index en dernier recours.
Formulaires — Reactive Forms typés
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
private readonly fb = inject(FormBuilder);
protected form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
role: ['', Validators.required],
});
protected get nameControl() {
return this.form.controls.name;
}
protected async onSubmit(): Promise<void> {
if (this.form.invalid) return;
this.isLoading.set(true);
try {
const value = this.form.getRawValue();
await this.myService.create(value);
this.router.navigate(['/success']);
} catch (error) {
this.errorMessage.set('Une erreur est survenue.');
} finally {
this.isLoading.set(false);
}
}
Template :
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field>
<mat-label>Nom</mat-label>
<input matInput formControlName="name" />
@if (nameControl.hasError('required') && nameControl.touched) {
<mat-error>Champ requis</mat-error>
}
</mat-form-field>
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || isLoading()">
@if (isLoading()) { Envoi en cours... } @else { Enregistrer }
</button>
</form>
Services — Pattern Axios
Ce projet utilise Axios (pas HttpClient). Toujours utiliser AxiosService via injection.
import { inject, Injectable } from '@angular/core';
import { AxiosService } from './axios.service';
import { MethodEnum } from '../enums/method.enum';
import { MyItemResponse, CreateMyItemRequest } from '../interfaces/my-item.interface';
@Injectable({
providedIn: 'root',
})
export class MyItemService {
private readonly axios = inject(AxiosService);
getAll(): Promise<MyItemResponse[]> {
return this.axios.request$<MyItemResponse[]>(MethodEnum.GET, '/my-items');
}
getById(id: string): Promise<MyItemResponse> {
return this.axios.request$<MyItemResponse>(MethodEnum.GET, `/my-items/${id}`);
}
create(request: CreateMyItemRequest): Promise<MyItemResponse> {
return this.axios.request$<MyItemResponse>(MethodEnum.POST, '/my-items', request);
}
update(id: string, request: Partial<CreateMyItemRequest>): Promise<MyItemResponse> {
return this.axios.request$<MyItemResponse>(MethodEnum.PUT, `/my-items/${id}`, request);
}
delete(id: string): Promise<void> {
return this.axios.request$<void>(MethodEnum.DELETE, `/my-items/${id}`);
}
}
Règles :
- Retourner des
Promise<T>, pas des Observable<T> pour les appels Axios.
private readonly axios — toujours private readonly.
- Les URLs d'API ne doivent jamais contenir le
base_url : AxiosService l'ajoute automatiquement.
- Toujours typer le générique
<T> de axios.request$.
Interfaces TypeScript — Alignement backend
Les interfaces frontend doivent correspondre exactement aux DTOs du backend (InfraFlowSculptor.Contracts).
export interface MyFeatureResponse {
id: string;
name: string;
location: string;
createdAt: string;
}
export interface CreateMyFeatureRequest {
name: string;
location: string;
}
export interface UpdateMyFeatureRequest {
name?: string;
location?: string;
}
Règles :
Guid backend → string frontend.
DateTime backend → string frontend.
- Pas d'
any ni unknown sans justification.
- Grouper toutes les interfaces d'une feature dans un seul fichier
feature.interface.ts.
Routing — Lazy Loading obligatoire
export const routes: Routes = [
{
path: 'login',
loadComponent: () =>
import('./features/login/login.component').then(m => m.LoginComponent),
},
{
path: 'infra-configs',
canActivate: [AuthenticationGuard],
loadComponent: () =>
import('./features/infra-config/infra-config-list.component')
.then(m => m.InfraConfigListComponent),
},
];
Règles :
- Toujours
loadComponent pour les routes features (jamais component: MyComponent directement).
- Utiliser
canActivate: [AuthenticationGuard] pour toutes les routes protégées.
- Préférer des routes imbriquées pour les sections d'une même feature.
Guards — Fonctions pures (pas classes)
import { inject } from '@angular/core';
import { Router, UrlTree } from '@angular/router';
import { MsalAuthService } from '../services/msal-auth.service';
export const MyGuard = async (): Promise<boolean | UrlTree> => {
const service = inject(MsalAuthService);
const router = inject(Router);
const isAllowed = await service.checkPermission();
return isAllowed ? true : router.createUrlTree(['/unauthorized']);
};
Angular Material + Tailwind — Utilisation combinée
Règle de base
- Angular Material : pour les composants interactifs (boutons, champs, dialogues, tableaux, etc.).
- Tailwind : pour le layout et l'espacement (flex, grid, p-, m-, gap-, w-, h-, etc.).
- SCSS scopé : pour les styles complexes ou les overrides Material.
Imports Material dans le composant
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialogModule } from '@angular/material/dialog';
import { MatSelectModule } from '@angular/material/select';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
Exemple de layout combiné
<div class="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
<mat-card>
<mat-card-header>
<mat-card-title>Titre</mat-card-title>
</mat-card-header>
<mat-card-content class="flex flex-col gap-4 mt-4">
</mat-card-content>
<mat-card-actions class="flex justify-end gap-2">
<button mat-stroked-button (click)="onCancel()">Annuler</button>
<button mat-raised-button color="primary" (click)="onSave()">Enregistrer</button>
</mat-card-actions>
</mat-card>
</div>
Theming SCSS
Ne pas dupliquer les couleurs ou tailles en dur dans le SCSS. Utiliser les variables CSS du thème Angular Material et les classes Tailwind. Si un style est spécifique à un composant, le définir dans le .scss scopé avec :host {}.
Design System — Intégration obligatoire
Règle absolue : Tout composant d'interface (écran, dialog, formulaire) doit utiliser les composants DS existants (app-ds-*) plutôt que de recréer des styles custom. Si un pattern UI n'a pas encore de composant DS, créer un composant DS réutilisable dans src/Front/src/app/shared/components/ds/ avant de l'utiliser dans l'écran.
Composants DS disponibles :
app-ds-button — boutons (primary, secondary, ghost, danger, success)
app-ds-card — cartes (elevated, outlined, glass)
app-ds-page-header — en-tête de page hero (gradient, plain)
app-ds-section-header — en-tête de section avec action slot
app-ds-alert — alertes inline (info, success, warning, error)
app-ds-text-field — champ texte (text, email, password, number, tel, url, date)
app-ds-textarea — zone de texte
app-ds-select — sélecteur
app-ds-toggle — toggle switch
app-ds-checkbox — case à cocher
app-ds-radio-group — groupe de boutons radio
app-ds-chip — badge/étiquette sémantique (info, success, warning, error)
app-ds-icon-button — bouton icône
app-ds-date-picker — sélecteur de date avec calendrier custom (CDK overlay, min/max, CVA, locale-aware)
app-ds-panel-action-button — bouton d'action panneau
Mixins SCSS réutilisables :
@include ifs-data-table — tableau de données flex avec tokens DS (dans scss/_tables.scss)
@include ifs-card-surface(...) — surface carte avec radius et shadow
@include ifs-hover-lift — effet de survol avec élévation
@include ifs-focus-visible — outline d'accessibilité
Interdit :
- Hardcoder des couleurs RGB/hex dans les composants — utiliser
$ifs-* tokens
- Créer un
.status-badge custom quand app-ds-chip existe
- Utiliser un
<input type="date"> brut quand app-ds-text-field type="date" existe
- Utiliser
mat-stroked-button pour un bouton annuler quand app-ds-button variant="secondary" existe
Pattern de chargement asynchrone — Smart Component
@Component({
selector: 'app-items-list',
standalone: true,
imports: [MatTableModule, MatProgressSpinnerModule, MatButtonModule],
templateUrl: './items-list.component.html',
styleUrl: './items-list.component.scss',
})
export class ItemsListComponent implements OnInit {
private readonly service = inject(ItemService);
private readonly router = inject(Router);
protected items = signal<ItemResponse[]>([]);
protected isLoading = signal(false);
protected errorMessage = signal('');
protected displayedColumns = ['name', 'location', 'actions'];
async ngOnInit(): Promise<void> {
await this.loadItems();
}
private async loadItems(): Promise<void> {
this.isLoading.set(true);
this.errorMessage.set('');
try {
const result = await this.service.getAll();
this.items.set(result);
} catch {
this.errorMessage.set('Erreur lors du chargement.');
} finally {
this.isLoading.set(false);
}
}
protected async onDelete(id: string): Promise<void> {
this.isLoading.set(true);
try {
await this.service.delete(id);
this.items.update(items => items.filter(i => i.id !== id));
} catch {
this.errorMessage.set('Erreur lors de la suppression.');
} finally {
this.isLoading.set(false);
}
}
protected navigateTo(id: string): void {
this.router.navigate(['/items', id]);
}
}
Environnements — URLs d'API
Jamais hardcoder une URL d'API dans un service ou un composant.
import { environment } from '../../../environments/environment';
const fullUrl = `${environment.api_url}/my-endpoint`;
En pratique, AxiosService lit automatiquement environment.api_url. Les services API n'ont pas besoin de l'importer directement — ils passent juste le chemin relatif.
| Config | Fichier | Usage |
|---|
development | environment.development.ts | npm run start |
aspire | environment.aspire.ts | npm run start:aspire (via Aspire, proxy) |
production | environment.ts | npm run build |
Gestion d'erreur — Patterns recommandés
Snackbar pour les erreurs utilisateur
import { MatSnackBar } from '@angular/material/snack-bar';
private readonly snackBar = inject(MatSnackBar);
private showError(message: string): void {
this.snackBar.open(message, 'Fermer', { duration: 5000, panelClass: 'error-snackbar' });
}
Signal d'erreur visible dans le template
protected errorMessage = signal('');
Enums TypeScript — Règles strictes
Placement et fichier dédié
Les enums DOIVENT être dans un fichier séparé — jamais définis dans le même fichier que le composant.
Arborescence :
- Si l'enum est utilisé par plusieurs features →
src/Front/src/app/shared/enums/{enum-name}.enum.ts
- Si l'enum est utilisé par une seule feature →
src/Front/src/app/features/{feature}/enums/{enum-name}.enum.ts
Backend Enum → Frontend Dropdown
Règle universelle : Si un champ est un enum au backend, il DOIT avoir un dropdown (<mat-select>) au frontend, pas un input texte libre.
Exemple :
- Backend :
Location.LocationEnum avec valeurs (EastUS, WestUS, etc.)
- Frontend : Créer
src/Front/src/app/features/config-detail/enums/location.enum.ts
- UI Component : Utiliser
<mat-select> avec <mat-option> pour chaque valeur enum
export enum LocationEnum {
EastUS = 'EastUS',
WestUS = 'WestUS',
}
export const LOCATION_OPTIONS = Object.entries(LocationEnum).map(([key, value]) => ({
label: key,
value,
}));
import { LOCATION_OPTIONS } from '../enums/location.enum';
export class MyComponent {
protected readonly locationOptions = LOCATION_OPTIONS;
}
<mat-form-field appearance="outline">
<mat-label>Location</mat-label>
<mat-select formControlName="location">
@for (option of locationOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
Conventions TypeScript
- Utiliser
readonly pour toutes les propriétés qui ne changent pas après l'initialisation.
- Typer explicitement les retours de fonctions async :
async load(): Promise<void>.
- Préférer
const et let plutôt que var.
- Pas de
any — utiliser des interfaces ou unknown + type guard si le type est inconnu.
- Utiliser
?. (optional chaining) et ?? (nullish coalescing) plutôt que les vérifications manuelles.
SCSS — Conventions
:host {
display: block;
}
.feature-card {
}
@media (max-width: 768px) {
.feature-card {
}
}
Internationalisation (i18n) — ngx-translate
Le projet utilise @ngx-translate/core + @ngx-translate/http-loader pour le runtime FR/EN (switch sans rebuild).
Ajouter TranslateModule à un composant
Tout composant qui affiche du texte UI doit importer TranslateModule :
import { TranslateModule } from '@ngx-translate/core';
@Component({
standalone: true,
imports: [CommonModule, MatButtonModule, TranslateModule ],
templateUrl: './my.component.html',
})
Pipe | translate dans les templates
<h1>{{ 'HOME.TITLE' | translate }}</h1>
<p>{{ 'HOME.FEEDBACK.CREATE_SUCCESS' | translate: { name: createdConfigName() } }}</p>
<input [placeholder]="'HOME.FORM.PLACEHOLDER' | translate" />
Convention des namespaces de traduction
Toutes les clés de traduction suivent une hiérarchie par écran/composant dans src/Front/public/i18n/{fr,en}.json :
| Namespace | Fichier / composant | Exemple de clé |
|---|
LANGUAGE | LanguageService, navigation | LANGUAGE.FRENCH_SHORT |
NAV | navigation.component | NAV.HOME, NAV.LOGOUT |
FOOTER | footer.component | FOOTER.RIGHTS |
LOGIN | login.component | LOGIN.TITLE, LOGIN.ERROR.MSAL_FAILED |
HOME | home.component | HOME.FORM.LABEL_NAME, HOME.LIST.EMPTY |
<NEW_FEATURE> | future composants | <FEATURE>.TITLE, <FEATURE>.ERROR.* |
Règle : Ne jamais mettre de texte UI en dur dans un template — toujours passer par une clé de traduction.
Ajouter de nouvelles clés
- Ajouter la clé dans
src/Front/public/i18n/fr.json (valeur française).
- Ajouter la même clé dans
src/Front/public/i18n/en.json (valeur anglaise).
- Les deux fichiers doivent avoir exactement les mêmes clés — pas de clé manquante dans une langue.
Pattern pour les messages d'erreur
Stocker la clé de traduction dans le signal, pas le texte final :
protected errorMessageKey = signal('');
this.errorMessageKey.set('LOGIN.ERROR.MSAL_FAILED');
@if (errorMessageKey()) {
<mat-error>{{ errorMessageKey() | translate }}</mat-error>
}
protected errorMessage = signal('Une erreur est survenue.');
Utiliser LanguageService dans un composant
Injecter LanguageService uniquement si le composant a besoin de :
- Afficher le sélecteur de langue.
- Réagir dynamiquement au changement de langue en TS (rare).
import { LanguageService } from '../../../shared/services/language.service';
protected readonly languageService = inject(LanguageService);
protected readonly currentLanguage = this.languageService.currentLanguage;
@for (lang of languageService.availableLanguages; track lang.code) {
<button
class="language-switch__button"
[class.language-switch__button--active]="currentLanguage() === lang.code"
(click)="languageService.setLanguage(lang.code)">
{{ lang.labelKey | translate }}
</button>
}
Baseline visuelle — Login et Home
Tous les nouveaux écrans authenticated doivent s'aligner sur la baseline visuelle validée.
Référence baseline
- Page de connexion :
src/Front/src/app/features/login/login.component.{html,scss}
- Page d'accueil :
src/Front/src/app/features/home/home.component.{html,scss}
Palette et tokens visuels
background: linear-gradient(135deg, #1a237e 0%, #0288d1 50%, #00bcd4 100%);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
backdrop-filter: blur(10px);
color: rgba(255, 255, 255, 0.9);
color: rgba(148, 203, 255, 0.85);
color: rgba(255, 255, 255, 0.6);
background: linear-gradient(135deg, #0288d1, #00bcd4);
color: #fff;
border-radius: 12px;
Convention typographique hero
h1 {
font-size: clamp(1.5rem, 2.2vw, 2.2rem);
line-height: 1.2;
max-width: 26ch;
font-weight: 700;
}
Piège validé : contraindre max-width à une valeur trop faible (ex : 12ch) force le titre à se découper en 6+ lignes. Toujours garder max-width ≥ 20ch pour les titres hero.
Piège validé — Timeline d'ordre (position visuelle vs order backend)
Pour les UIs de réordonnancement (flèches gauche/droite + timeline) :
- La timeline manipule une position 1-based (
1..N+1)
- Le backend manipule une valeur
order qui peut être non contiguë
Règle de conversion en mode édition :
- Déplacement vers la gauche: envoyer l'order de l'élément au slot cible (
others[position - 1].order)
- Déplacement vers la droite: envoyer l'order de l'élément "sauté" (
others[position - 2].order)
Anti-pattern à éviter : payload.order = currentPosition ou un mapping identique gauche/droite.
Layout hero 2 colonnes
.hero-panel {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 1fr);
gap: 2rem;
align-items: start;
}
.hero-panel__content {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 1.1rem;
}