Développement d’applications d’entreprise 2

Outputs

Outputs

Pour favoriser la séparation des responsabilités, les composants doivent être en mesure de communiquer entre eux de façon bi-directionnelle: Input et Output.

👉 Gérer la navigation vers la page détail d'un bâtiment dans le Home:
LocationComponent -- (output) --> Home

angular
Home
import {Component, inject} from '@angular/core';
import {Router, ActivatedRoute} from '@angular/router';

import {LocationComponent} from './location.component';
import {Location} from 'models/location.interface';

@Component({
selector: 'app-home',
imports: [
LocationComponent
],
template: `
...

<app-location [location]="location" (detailsClick)="handleLocationClicked($event)"></app-location>

...
`
,
styles: ` ... `
})
export class HomePage {

...

handleLocationClicked(id: number) {
console.log({ id })
this.router.navigate(['/details', id])
}

}
angular
Location
import {Component, Input, output} from '@angular/core';
import {CommonModule} from '@angular/common';
import {Location} from 'models/location.interface';

@Component({
selector: 'app-location',
imports: [
CommonModule, RouterModule,
],
template: `
...

<button mat-raised-button (click)="handleClick()">Details</button>

...
`
,
styles: `...`
})
export class LocationComponent {
@Input() location!: Location
detailsClick = output<number>()

handleClick() {
this.detailsClick.emit(this.location.id)
}
}

Validations des formulaires

Validations

  • Attention On peut appliquer les validations sur un champ, control, pour une validation individuelle ou sur le formulaire, form group, pour vérifier une concordance entre plusieurs champs
ts
new FormControl(null, [Validators.required, Validators.email, ...])
// Le 1er parametre est la valeur initiale du contrôle

Il est également possible d'utiliser une fonction de validation personnalisée.

Ensuite dans le template on peut vérifier le statut d'un FormControl ou FormGroup, voir leur super classe AbstractControl pour toutes les propriétés et méthodes.

ts
formOrControl.valid
formOrControl.invalid
formOrControl.errors

formOrControl.dirty // la valeur a changée via le UI
formOrControl.touched // le champ a été focus puis quitté

form.get('myFormControlName')?.xyz // Recuperer un form control du form
// peut etre null, donc ? pour le chaînage optionel

On peut exploiter les variables locales des templates pour récupérer si le formulaire à été soumis

html
<form #form="ngForm">
<!-- form.submitted est disponible dans les expressions -->
@if(form.submitted) {
...
}
</form>

En utilisant Angular Material, plusieurs mécanisme encadrent l'affichage des erreurs

👉 Ajouter quelques validations sur le formulaire d'inscription

angular
import {Component, inject} from '@angular/core';
import {JsonPipe} from '@angular/common';
import {RouterModule, Router} from '@angular/router';
import {FormControl, FormGroup, FormGroupDirective, NgForm, AbstractControl, ReactiveFormsModule, Validators, ValidationErrors} from '@angular/forms';

import {MatFormFieldModule} from '@angular/material/form-field';
import {MatIconModule} from '@angular/material/icon';
import {MatInputModule} from '@angular/material/input';
import {MatButtonModule} from '@angular/material/button';
import {ErrorStateMatcher} from '@angular/material/core';

@Component({
selector: 'app-signup',
imports: [
RouterModule, ReactiveFormsModule, JsonPipe,
MatFormFieldModule, MatInputModule, MatIconModule, MatButtonModule
],
template: `
<form [formGroup]="signupForm" (submit)="handleSubmit()">
<mat-form-field appearance="outline">

<mat-label>Username</mat-label>
<input formControlName="username" matInput>

@if(usernameControl.hasError('required')) {
<mat-error>Please enter a username</mat-error>
}
@else if(usernameControl.hasError('email')) {
<mat-error>Username must be an email</mat-error>
}

</mat-form-field>

<mat-form-field appearance="outline">

<mat-label>Password</mat-label>
<input formControlName="password" matInput type="password">

<mat-error>Please enter a password</mat-error>

</mat-form-field>

<mat-form-field appearance="outline">
<mat-label>Password confirmation</mat-label>
<input formControlName="passwordConfirmation" [errorStateMatcher]="confirmationMatcher" matInput type="password">

@if(passwordConfirmationControl.hasError('required')) {
<mat-error>Please enter a password confirmation</mat-error>
}
@else if(signupForm.hasError('passwordConfirmationMustMatch')) {
<mat-error>Password confirmation must match password</mat-error>
}

</mat-form-field>

<div id="buttons">
<button mat-flat-button>Sign up</button>

<a mat-button routerLink="/login">Cancel</a>
</div>
</form>

<pre>
{{
{
username: signupForm.get('username')?.errors,
password: signupForm.get('password')?.errors,
passwordConfirmation: signupForm.get('passwordConfirmation')?.errors,
signupForm: signupForm.errors,
signupFormValid: signupForm.valid
} | json
}}
</pre>
`
,
styles: ` ... `
})
export class SignupPage {
private readonly router = inject(Router)

passwordControl = new FormControl('', [Validators.required])
passwordConfirmationControl = new FormControl('', [Validators.required])

signupForm = new FormGroup({
username: new FormControl('', [Validators.required, Validators.email]),
password: this.passwordControl,
passwordConfirmation: this.passwordConfirmationControl
}, [this.passwordMatch])

confirmationMatcher = new ConfirmationMatcher()

private passwordMatch(form: AbstractControl): ValidationErrors | null {

if (form.value?.password != form.value?.passwordConfirmation) {
return { passwordConfirmationMustMatch: true };
} else {
return null
}

}

get usernameControl(): FormControl {
return this.signupForm.get('username') as FormControl
}

handleSubmit() {

if (this.signupForm.valid) {
this.router.navigate(['/'])
}

}
}

class ConfirmationMatcher implements ErrorStateMatcher {
// Pour lier l'affichage de l'erreur sur le FORM avec le CONTROL

isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const isSubmitted = form && form.submitted;

return !!(
control
&& (control.invalid || form?.hasError('passwordConfirmationMustMatch')) // FORM
&& (control.dirty || control.touched || isSubmitted)
);
}
}

Modèles

Modeling entities in TS

Interface vs Classes?
  • Interface donne la forme, un contrat
  • Classe encapsule le comportement ET les données

Modèles vs Services?
  • Modèle est une représentation stricte des entitées
  • Service expose les opérations de manipulation des entitées

MVC?

👉 Importer les fichiers modèles dans votre projet

Fichiers Modèles
ts
copier
export class InsertResult {

readonly success: boolean
readonly lastInsertId: number

constructor(value: InsertResult) {
this.success = value.success
this.lastInsertId = value.lastInsertId
}

}
ts
copier
export class User {

constructor(
public email: string
) { }

}
ts
copier
export class UserCredentials {
readonly email: string
readonly password: string

constructor(value: { username: string, password: string }) {
this.email = value.username.toLowerCase()
this.password = value.password
}

}

Martha

http://martha.jh.shawinigan.info

Fichiers Martha
sql
dump.sql
copier
create table users (
email varchar(50) not null,
passwd varchar(64) not null,
primary key (email)
);

insert into users(email, passwd)
values
("a@a", sha2('aaa', 256)),
("b@b", sha2('bbb', 256)),
("c@c", sha2('ccc', 256));
json
queries.json
copier
[
{
"name": "users-login",
"string": "select email from users where email = '?email' && passwd = sha2('?password', 256);",
"type": 0
},
{
"name": "users-signup",
"string": "insert into users(email, passwd) values ('?email', sha2('?password', 256));",
"type": 1
}
]

HTTP

HTTP

vs Fetch/Promises?

Observables vs Promises

  • Promise est exécutée immédiatement
    • Observable attend un subscribe
  • Promise émet une seule valeur
    • Observable potentiellement plusieurs
  • Promise traite le résultat dans then ou await
    • Observable offre les opérateurs RxJS

Observables?

HTTP Observables

RxJS Observables, RxJS Operators

RxJS Marbles, Learn RxJS

Les Observables sont utilisés pour la gestion de plusieurs tâches traitées de façon asynchrone par Angular(HTTP, Routing, Forms). Il est possible de créer ses propres Observables pour communiquer des données entre différents éléments de notre application.

ATTENTION Une requête HTTP n'est lancée que si on lui subscribe!

+------------+ +-------------+
| | notifie | |
| Observer +<--<--<--<--<--+ Observable |
| | (pipe) | |
+-----+------+ +------+------+
| ^
| |
+-----------------------------+
subscribes

Interceptors?

HTTP Interceptors

Si on veut capturer les requêtes OU réponses HTTP globalement dans l'application, il est possible d'appliquer un filtre/middleware(un peu comme les guard du routeur) pour valider et traiter les données avant que le processus ne se poursuive.

👉 Implémenter la connexion et l'inscription via Martha

Services
ts
Martha
copier
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {Observable, of} from 'rxjs';
import {map, catchError, tap} from 'rxjs/operators';
import {InsertResult} from '../models/insert-result.model';

@Injectable({
providedIn: 'root'
})
export class MarthaRequestService {
private readonly username = 'USERNAME';
private readonly password = 'PASSWORD';

constructor(private http: HttpClient) { }

private get headers() {
return { headers: {'auth' : btoa(`${this.username}:${this.password}`)}};
}

private getUrl(query: string) {
return `http://martha.jh.shawinigan.info/queries/${query}/execute`;
}

select(query: string, body: any = null): Observable<any | null> {
return this.http.post<any>(this.getUrl(query), body, this.headers).pipe(
map(response => {
console.log('Martha select', response);

if (response.success) {
return response.data;
} else {
return false;
}
}),
catchError(error => {
console.log('Error', error);

return of(null);
})
);
}

insert(query: string, body: any = null): Observable<InsertResult | null> {
return this.http.post<any>(this.getUrl(query), body, this.headers).pipe(
map(result => {
console.log('Martha insert', result);

return new InsertResult(result);
}),
catchError(error => {
console.log('Error', error);

return of(null);
})
);
}

statement(query: string, body: any = null): Observable<boolean | null> {
return this.http.post<any>(this.getUrl(query), body, this.headers).pipe(
tap(response => console.log('Martha statement', response)),
map(result => result.success ?? false),
catchError(error => {
console.log('Error', error);

return of(null);
})
);
}
}
ts
Auth
copier
import {Injectable} from '@angular/core';
import {User} from 'models/user.model';
import {UserCredentials} from 'models/user-credentials.model';
import {MarthaRequestService} from 'services/martha-request.service';
import {map} from 'rxjs/operators';
import {Observable} from 'rxjs';

@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly CURRENT_USER_KEY = 'recipeasy.currentUser';

private _currentUser : User | null = null;

get currentUser(): User | null {
return this._currentUser;
}

get isLoggedIn(): boolean {
return !!this._currentUser;
}

constructor(private martha : MarthaRequestService) {
const storedCurrentUser = JSON.parse(localStorage.getItem(this.CURRENT_USER_KEY) ?? 'null');

if (storedCurrentUser) {
this._currentUser = new User(storedCurrentUser.email);
}
}


private setCurrentUser(user: User | null) {
this._currentUser = user;
localStorage.setItem(this.CURRENT_USER_KEY, JSON.stringify(user));
}

logIn(credentials: UserCredentials): Observable<boolean> {

return this.martha.select('users-login', credentials).pipe(
map(data => {

if (data.length == 1) {
this.setCurrentUser(new User(data[0].email));

return true;
} else {
return false;
}

})
);

}

signUp(credentials: UserCredentials): Observable<boolean> {

console.log({ credentials })

return this.martha.insert('users-signup', credentials).pipe(
map(result => {
if (result?.success) {
this.setCurrentUser(new User(credentials.email));

return true;
} else {
return false;
}
})
);

}

logOut() {
this.setCurrentUser(null);
}

}

Components
ts
Inscription
copier
import {Component, inject} from '@angular/core';
import {JsonPipe} from '@angular/common';
import {RouterModule, Router} from '@angular/router';
import {FormControl, FormGroup, FormGroupDirective, NgForm, AbstractControl, ReactiveFormsModule, Validators, ValidationErrors} from '@angular/forms';

import {AuthService} from 'services/auth.service';
import {UserCredentials} from 'models/user-credentials.model';

@Component({
selector: 'app-signup',
imports: [
RouterModule, ReactiveFormsModule, JsonPipe,
MatFormFieldModule, MatInputModule, MatIconModule, MatButtonModule
],
template: `
<form [formGroup]="signupForm" (submit)="handleSubmit()">

@if (error) {
<div id="error">
{{ error }}
</div>
}

<mat-form-field appearance="outline">

<mat-label>Username</mat-label>
<input formControlName="username" matInput>

@if(usernameControl.hasError('required')) {
<mat-error>Please enter a username</mat-error>
}
@else if(usernameControl.hasError('email')) {
<mat-error>Username must be an email</mat-error>
}

</mat-form-field>

<mat-form-field appearance="outline">

<mat-label>Password</mat-label>
<input formControlName="password" matInput type="password">

<mat-error>Please enter a password</mat-error>

</mat-form-field>

<mat-form-field appearance="outline">
<mat-label>Password confirmation</mat-label>
<input formControlName="passwordConfirmation" [errorStateMatcher]="confirmationMatcher" matInput type="password">

@if(passwordConfirmationControl.hasError('required')) {
<mat-error>Please enter a password confirmation</mat-error>
}
@else if(signupForm.hasError('passwordConfirmationMustMatch')) {
<mat-error>Password confirmation must match password</mat-error>
}

</mat-form-field>

<div id="buttons">
<button mat-flat-button>Sign up</button>

<a mat-button routerLink="/login">Cancel</a>
</div>
</form>

<pre>
{{
{
username: signupForm.get('username')?.errors,
password: signupForm.get('password')?.errors,
passwordConfirmation: signupForm.get('passwordConfirmation')?.errors,
signupForm: signupForm.errors,
signupFormValid: signupForm.valid
} | json
}}
</pre>
`
,
styles: `
#error {
background-color: pink;
border-radius: 4px;
padding: 8px;
margin-bottom: 16px;
color: darkred;
}
`

})
export class SignupPage {
private readonly router = inject(Router)
private readonly auth = inject(AuthService)

error: string | null = null

passwordControl = new FormControl('', [Validators.required])
passwordConfirmationControl = new FormControl('', [Validators.required])

signupForm = new FormGroup({
username: new FormControl('', [Validators.required, Validators.email]),
password: this.passwordControl,
passwordConfirmation: this.passwordConfirmationControl
}, [this.passwordMatch])

confirmationMatcher = new ConfirmationMatcher()

private passwordMatch(form: AbstractControl): ValidationErrors | null {

if (form.value?.password != form.value?.passwordConfirmation) {
return { passwordConfirmationMustMatch: true };
} else {
return null
}

}

get usernameControl(): FormControl {
return this.signupForm.get('username') as FormControl
}

handleSubmit() {

if (this.signupForm.valid) {
console.log(this.signupForm.value)
const { username, password } = this.signupForm.value
const credentials = new UserCredentials({ username: username!, password: password! })

this.auth.signUp(credentials).subscribe( success => {
if (success) {
this.router.navigate(['/'])
}
else {
this.error = 'Could not create account'
}
})
}

}
}

class ConfirmationMatcher implements ErrorStateMatcher {
// Pour lier l'affichage de l'erreur sur le FORM avec le CONTROL
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const isSubmitted = form && form.submitted;

return !!(
control
&& (control.invalid || form?.hasError('passwordConfirmationMustMatch'))
&& (control.dirty || control.touched || isSubmitted)
);
}
}
ts
Connexion
copier
import { Component, inject } from '@angular/core';
import { RouterModule, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';

import {AuthService} from 'services/auth.service'
import {UserCredentials} from 'models/user-credentials.model'

@Component({
selector: 'app-login',
imports: [
RouterModule, FormsModule,
MatFormFieldModule, MatInputModule, MatIconModule, MatButtonModule
],
template: `
<form (submit)="handleSubmit(usernameInput.value, passwordInput.value)">
@if (error) {
<div id="error">
{{ error }}
</div>
}

<mat-form-field appearance="outline">
<mat-label>Username</mat-label>
<input #usernameInput matInput>
</mat-form-field>

<mat-form-field appearance="outline">
<mat-label>Password</mat-label>
<input #passwordInput matInput type="password">
</mat-form-field>

<div id="buttons">
<button mat-flat-button>Log in</button>

<a mat-button routerLink="/signup">Create an account!</a>
</div>
</form>
`
,
styles: `
#error {
background-color: pink;
border-radius: 4px;
padding: 8px;
margin-bottom: 16px;
color: darkred;
}
`

})
export class LoginPage {
private readonly router = inject(Router)
private readonly auth = inject(AuthService)

error: string | null = null

handleSubmit(username: string, password: string) {

const credentials = new UserCredentials({ username, password })

this.auth.logIn(credentials).subscribe( success => {
if (success) {
this.router.navigate(['/'])
}
else {
this.error = 'Invalid credentials'
}
})

}
}
ts
App Nav
copier
import {Component, inject} from '@angular/core';
import {RouterModule} from '@angular/router';

import {AuthService} from 'services/auth.service';

@Component({
selector: 'app-root',
imports: [
RouterModule,
MatToolbarModule, MatIconModule
],
template: `
<mat-toolbar>
<a routerLink="/" id="home">
<mat-icon>home</mat-icon>
<span>Housing</span>
</a>

<span class="spacer"></span>

@if (auth.isLoggedIn) {
<a routerLink="/" (click)="handleLogOut()">Log out</a>
}
@else {
<a routerLink="/login">Log in</a>
}
</mat-toolbar>

...
`
,
styles: ` ... `
})
export class AppComponent {
auth = inject(AuthService)

handleLogOut() {
this.auth.logOut()
}
}

Internationalisation

i18n

Le mécanisme d'internationalisation d'Angular est très complet mais également imposant à mettre en place. Plus particulièrement, il demande de déployer une version de l'application pour chaque langue et rend difficile de changer la langue dynamiquement à l'exécution.

La librairie ngx-translate nous permets de supporter facilement plusieurs langues dans notre application.

Déploiement

Déploiement

ng build

bash
Voir defaultConfiguration dans angular.json
copier
ng build # --c production

Les fichiers générés sont des artéfacts statiques pouvant être servis par n'importe quel serveur web, assurez-vous de rediriger toutes les requêtes qui auraient menée à un 404 Not Found vers le fichiers index.html de votre application pour gérer les routes via le code client Angular(ex. avec Apache).

Il est possible d'utiliser des variables d'environnement qui seront différentes en développement vs en production(ex: URL de serveur, log de debug);

Certaines librairie, comme ts-json-object, ne sont pas optimisées pour le processus de build Angular. Il faut ajouter une exception dans la configuration du projet dans angular.json pour indiquer à Angular de les ignorer.

json
copier
"allowedCommonJsDependencies": [
"ts-json-object"
],