Nous allons poursuivre l'article sur Angular-Electronjs : mise en forme de la page de connexion.
On abordera les élements suivants :
- Nettoyage du code et factorisation.
- Messages d’erreurs.
- Formulaire HTML – Ajout du « reactive forms« .
- Typescript – Validation du formulaire grâce à une api développé avec nodeexpress-js
Nettoyage du code et factorisation
Faisons un peu de ménage dans notre code.
Modules supplémentaires
En premier lieu, nous allons modifier l’utilisation de fontawesome. Il existe un module fontawesome pour Angular .
Le code classique sera remplacé par celui-ci
<fa-icon icon="['fas', 'spinner']" spin="true"></fa-icon>
On désinstalle tout d’abord @fortawesome/fontawesome-free
npm uninstall @fortawesome/fontawesome-free --save
Puis dans /src/styles.scss, supprimons l’entrée suivante
$fa-font-path : '../node_modules/@fortawesome/fontawesome-free/webfonts';
@import '../node_modules/@fortawesome/fontawesome-free/scss/fontawesome';
Ce remplacement nécessite l’installation de packages supplémentaires. Et on installe les packages suivants :
- @fortawesome/fontawesome-svg-core
- @fortawesome/free-solid-svg-icons
- @fortawesome/angular-fontawesome
Cette installation se fait en une seule fois avec la commande suivante :
npm install @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/angular-fontawesome --save
Pour pouvoir utiliser ce package dans l’ensemble de l’application, modifions /src/app/app.module.ts et ajoutons fontawesome et d’autres components material tels que: MatSnackBarModule et MatProgressBarModule.
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { library } from '@fortawesome/fontawesome-svg-core';
import { fas } from '@fortawesome/free-solid-svg-icons';
import {
MatSidenavModule,
MatToolbarModule,
MatIconModule,
MatCardModule,
MatInputModule,
MatFormFieldModule,
MatButtonModule,
MatListModule,
MatExpansionModule,
MatSnackBarModule,
MatProgressBarModule
} from '@angular/material';
…puis on ajoute dans NgModule
@NgModule({
imports: [
// Material
MatSidenavModule,
MatButtonModule,
MatToolbarModule,
MatIconModule,
MatCardModule,
MatListModule,
MatInputModule,
MatFormFieldModule,
MatProgressBarModule,
MatSnackBarModule,
MatExpansionModule,
FlexLayoutModule,
ReactiveFormsModule,
// Fontawesome
FontAwesomeModule
]
});
Messages d’erreurs
L’affichage des messages d’erreurs du formulaire de connexion se fait à 2 endroits:
- dans le fichier /src/app/components/login/login.component.html
- et dans typescript /src/app/components/login/login.component.ts
…Dans le fichier login.component.html
Ajoutons un message d’erreur sur la validation du formulaire. Ce message se présentera lorsque le couple identifiant/mot de passe saisi ne correspond pas au couple enregistré en base de données.
Ce message d’erreur sera affiché par le bout de code suivant placé juste après la balise d’ouverture <form>
<div *ngIf="errorForm" fxLayout="row" fxLayoutGap="5px" fxLayoutAlign="center">
<div><mat-icon aria-hidden="false" color="warn" >error</mat-icon></div>
<div>{{ 'error.form' | translate }}</div>
</div>
Voici un exemple :
Grâce à la directive *ngIf="errorForm"
, ce bloc de message s’affiche si la propriété errorForm est définie à true. Nous allons définir cette propriété un peu plus bas.
Les propriétés fx*
sont des attributs specifiques à Angular flex layout. Elles permettent de mettre en forme les éléments.
Nous allons également afficher les messages d’erreurs au dessous de chaque champ du formulaire (identifiant et mot de passe).
La balise du champ identifiant sera comme ceci
<mat-form-field>
<input [attr.disabled]="savedForm ? '' : null" [readonly]="savedForm" formControlName="username" matInput placeholder="{{ 'login.email_placeholder' | translate }}">
<mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
<mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
<mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
</mat-form-field>
Sur ce champ, nous avons ajouté 2 messages d’erreurs qui apparaissent si les validations suivantes ne sont pas respectées :
- champ obligatoire required
- champ contenant moins de caractère que ce qui est requis minlength
- champ contenant plus de caractère que ce qui est requis maxlength
Les messages d’erreurs sur ce champ sont conditionnés par la valeur true des conditions usernameControl.dirty && usernameControl.hasError('required')
qui peuvent se résumer ainsi :
- Afficher ce bloc de texte si le champ identifiant et vide et contient une erreur de type « obligatoire »
Il en est de même pour les 2 autres blocs d’erreurs.
On fait la même chose pour le champ mot de passe. Ce champ possède les mêmes contraintes de validation que le champ identifiant. La balise sera modifiée comme ceci
<mat-form-field >
<input [type]="hidePassword ? 'password' : 'text'" [attr.disabled]="savedForm ? '' : null" formControlName="password" matInput placeholder="{{ 'login.password_placeholder' | translate }}">
<a mat-icon-button matSuffix (click)="hidePassword = !hidePassword" [attr.aria-label]="'Hide password'" [attr.aria-pressed]="hidePassword">
<mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
</a>
<mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
<mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
<mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
</mat-form-field>
Dans les 2 cas, nous avons ajouté [attr.disabled]="savedForm ? '' : null" [readonly]="savedForm"
qui met le champ en lecture seule et désactive le champs si la propriété savedForm est à true (C’est-à-dire Formulaire en cours de traitement).
Formulaire HTML – Ajout du reactive forms.
Comme dans les champs de formulaire vus plus haut, on ajoute une propriété [disabled]
au bouton de validation. Voici comment se présente le code html de ce bouton.
<button mat-raised-button color="primary" [disabled]="loginForm.invalid || savedForm">
<fa-icon *ngIf="savedForm" [icon]="['fas', 'spinner']" [spin]="true"></fa-icon>
{{ 'login.button' | translate }}
</button>
2 propriétés boolean doivent être à true pour changer la propriété du bouton à « disabled » :
- loginForm.invalid
Formulaire invalide
- savedForm
Formulaire en cours de traitement.
Iil est logique de désactiver le bouton de validation pour éviter le clic intempestif sur ce bouton dans ces 2 cas.
Le code suivant :
<fa-icon *ngIf="savedForm" [icon]="['fas', 'spinner']" [spin]="true"></fa-icon>
est une icone fontawesome animée à gauche du texte du bouton Elle s’affiche uniquement (grâce à la propriété *ngIf
) en cas de validation du formulaire.
C’est un moyen de montrer à l’utilisateur que sa demande est en cours de traitement.
Typescript – Validation du formulaire et connexion à une api en nodeexpress-js
Pour rappel, il existe deux méthodes permettant de piloter (créer, valider) un formulaire avec Angular:
- par un template
- par le code
Dans la suite de cet article, nous utilisons la méthode pilotée par le code.
Importons ReactiveFormsModule dans le fichier app/app.module.ts
import { ReactiveFormsModule } from '@angular/forms';
puis ajoutons dans NgModule
@NgModule({
imports: [
ReactiveFormsModule
]
})
Template html login
Modifions notre fichier html /src/app/components/login/login.component.html – ajoutons la balise <form>
avec la directive formGroup
<form novalidate
[formGroup]="loginForm"
(ngSubmit)="onSubmit()"
[hidden]="hideForm"
>
La directive (ngSubmit)= »onSubmit() » définit une méthode onSubmit()
à appeler lorsque le formulaire sera validé.
On modifie ensuite les champs identifiant et mot de passe en ajoutant la directive formControlName
.
Ce qui donne
<input [attr.disabled]="savedForm" [readonly]="savedForm" formControlName="username" matInput placeholder="{{ 'login.email_placeholder' | translate }}">
On fait une petite modification pour le champ mot de passe
<input [type]="hidePassword ? 'password' : 'text'" [attr.disabled]="savedForm" formControlName="password" matInput placeholder="{{ 'login.password_placeholder' | translate }}">
<button mat-icon-button matSuffix (click)="hidePassword = !hidePassword" [attr.aria-label]="'Hide password'" [attr.aria-pressed]="hidePassword">
<mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
</button>
Le bouton à droite du champ mot de passe est une icone cliquable qui affiche en clair le mot de passe saisi. Elle permet de le masquer ensuite en cliquant à nouveau sur la même icone.
Le code complet du champ mot de passe doit ressembler à ceci après ajout du message d’erreur vu plus haut.
<mat-form-field >
<input [type]="hidePassword ? 'password' : 'text'" [attr.disabled]="savedForm" formControlName="password" matInput placeholder="{{ 'login.password_placeholder' | translate }}">
<button mat-icon-button matSuffix (click)="hidePassword = !hidePassword" [attr.aria-label]="'Hide password'" [attr.aria-pressed]="hidePassword">
<mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
</button>
<mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
<mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
<mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
</mat-form-field>
Ajoutons une barre de progression avant la fin du formulaire. Cette barre de progression s’affiche si le formulaire est en cours de traitement.
<mat-progress-bar color="accent" *ngIf="hideForm || savedForm" mode="indeterminate"></mat-progress-bar>
Et on n’oublie pas de fermer le formulaire
</form>
Notre fichier complet /src/app/components/login/login.component.html est le suivant
<form novalidate
[formGroup]="loginForm"
(ngSubmit)="onSubmit()"
>
<div fxLayout="row" fxLayoutAlign="center" class="login-main">
<mat-card >
<mat-card-header>
<mat-card-title>{{ 'login.header' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content fxLayout="column">
<div *ngIf="errorForm" fxLayout="row" fxLayoutGap="5px" fxLayoutAlign="center">
<div><mat-icon aria-hidden="false" color="warn" >error</mat-icon></div>
<div>{{ 'error.form' | translate }}</div>
</div>
<mat-form-field>
<input [attr.disabled]="savedForm" [readonly]="savedForm" formControlName="username" matInput placeholder="{{ 'login.email_placeholder' | translate }}">
<mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
<mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
<mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
</mat-form-field>
<mat-form-field >
<input [type]="hidePassword ? 'password' : 'text'" [attr.disabled]="savedForm" formControlName="password" matInput placeholder="{{ 'login.password_placeholder' | translate }}">
<button mat-icon-button matSuffix (click)="hidePassword = !hidePassword" [attr.aria-label]="'Hide password'" [attr.aria-pressed]="hidePassword">
<mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
</button>
<mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
<mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
<mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
</mat-form-field>
</mat-card-content>
<mat-card-actions align="end">
<button mat-raised-button color="primary" [disabled]="loginForm.invalid || savedForm">
<fa-icon *ngIf="savedForm" [icon]="['fas', 'spinner']" [spin]="true"></fa-icon>
{{ 'login.button' | translate }}
</button>
</mat-card-actions>
<mat-progress-bar color="accent" *ngIf="hideForm || savedForm" mode="indeterminate"></mat-progress-bar>
</mat-card>
</div>
</form>
… côté Typescript
Modifions le fichier /src/app/components/login/login.components.ts et importons les class utiles permettant de gérer les formulaires
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
Ajoutons les propriétés suivantes
hideForm: boolean;
savedForm: boolean;
errorForm: boolean;
hidePassword: boolean = true;
loginForm: FormGroup;
usernameControl: FormControl;
passwordControl: FormControl;
Importons les class utiles telles que Router, ActivatedRoute et MatSnackBar . Puis dans le constructeur, injectons les class qui nous seront utiles. Ce qui donne ceci
import { Router, ActivatedRoute } from '@angular/router';
import { MatSnackBar } from '@angular/material';
import { TranslateService } from "@ngx-translate/core";
constructor(private fb: FormBuilder,
public service:LoginService,
private router: Router,
private route: ActivatedRoute,
private _snackBar: MatSnackBar,
private translate: TranslateService)
Ajoutons une méthode createForm
constructor(private fb: FormBuilder) {
this.createForm();
}
Et créons cette méthode
createForm() {
this.loginForm = this.fb.group({
username: this.fb.control('', [Validators.required, Validators.minLength(2), Validators.maxLength(25)]),
password: this.fb.control('', [Validators.required, Validators.minLength(2), Validators.maxLength(25)])
});
this.loginForm.valueChanges
.subscribe(data => this.onValueChanged(data));
// Reset
this.hideForm = false;
}
On crée 2 services dans le dossier providers :
- login
servira à l’authentification (login, logout) - user
servira à afficher les informations de l’utilisateur
connecté
La création de ces services se fait grâce aux commandes de Angular suivantes
ng g service providers/login
ng g service providers/user
Les fichiers suivants sont créés
- /src/app/providers/login.service.spec.ts
- /src/app/providers/login.service.ts
- /src/app/providers/user.service.spec.ts
- /src/app/providers/user.service.ts
Dans le constructeur, ajoutons le code suivant
// if url "/login?logout=1"
let logoutParam = this.route.snapshot.queryParamMap.get('logout');
if (logoutParam == '1') {
this.logout();
}
// redirect to home if already logged in
if (this.service.currentUserValue) {
this.router.navigate(['/']);
}
Les lignes 2 à 6 du code précédent détectent la présence de l’URL « ?logout=1 » et lance la déconnexion de l’utilisateur si cet URL est lancé.
On considère que la page home accessible via l’URL « / » est une page réservée aux utilisateurs authentifiés. Le code ci-dessous (ligne 8 – 10) redirige l’utilisateur vers la page « / » si celui ci est authentifié.
On ajoute la méthode logout
dans la class LoginComponent.
logout () {
this.service.logout();
}
Soumission du formulaire
La soumission du formulaire se gère grâce à la méthode onSubmit
suivante.
onSubmit() {
// True == form en cours de traitement
this.savedForm = true;
// supprimer les messages d'erreurs
this.errorForm = false;
// Valeurs des champs du formulaire
let values = this.loginForm.value;
// Service LoginService
this.service.login(values)
.pipe(first())
.subscribe((result) => {
// Token existe dans retour API
let tokenExists = typeof result.token !== 'undefined';
// Cacher le formulaire et preparer la redirection vers la home
this.hideForm = true;
if (tokenExists) {
this.router.navigate(['/']);
return true;
}
// Afficher error en cas d'echec
this.setFormError();
}, (err) => {
console.log(err);
// Afficher error en cas d'echec
this.setFormError();
});
}
setFormError() {
// Afficher le formulaire
this.hideForm = false;
// Le formulaire n'est plus en cours de traitement
this.savedForm = false;
// Afficher les messages d'erreurs
this.errorForm = true;
// Afficher notif dans snackbar
let msgSnack = this.translate.instant('error.form');
this._snackBar.open(msgSnack, null, {
duration: 5000,
});
}
La ligne 49 à 53 affiche un message d’erreur sous forme de notification en bas de page comme sur cette capture.
L’essentiel du traitement se fait dans le service LoginService
.
Fichier /src/app/providers/login.service.ts
On ajoute 2 méthodes login
et logout
.
La methode login
Cette méthode se connecte à l’URL ${AppConfig.apiUrl}/user/login
en méthode POST et passe les informations login=&password= dans la variable data
. Un token est retournée par l’URL en cas de succes. Ce token sera enregistrée dans le localStorage.
la variable ${AppConfig.apiUrl}
est définie dans le fichier /src/environments/environment.ts comme suit apiUrl: 'http://localhost:3008/api'
Le contenu de ce fichier est le suivant :
export const AppConfig = {
production: false,
environment: 'LOCAL',
apiUrl: 'http://localhost:3008/api'
};
la variable apiUrl
appelle une Url qui est une application nodejs. Une fois la connexion réussie, le token retournée par l’url sera enregistrée dans localstorage
via localStorage.setItem
login (data): Observable<any> {
// console.log(data);
return this.http.post<any>(`${AppConfig.apiUrl}/user/login`, data, httpOptions)
.pipe(map(user => {
// store user details and jwt token in local storage to keep user logged in between page refreshes
localStorage.setItem('user', JSON.stringify(user));
this.currentUserSubject.next(user);
return user;
}));
}
La méthode logout
Cette méthode quand à elle supprime la variable locale dans localStorage
ensuite détruit la variable currentUserSubject
logout() {
// remove user from local storage to log user out
localStorage.removeItem('user');
this.currentUserSubject.next(null);
}
Code complet /src/app/providers/login.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { User } from '../models/user';
import { map, tap } from "rxjs/operators";
import { AppConfig } from '../../environments/environment';
const httpOptions = {
headers: new HttpHeaders({
'Content-Type': 'application/json'
})
};
@Injectable({
providedIn: 'root'
})
export class LoginService {
private currentUserSubject: BehaviorSubject<User>;
public currentUser: Observable<User>;
constructor(private http: HttpClient) {
this.currentUserSubject = new BehaviorSubject<User>(JSON.parse(localStorage.getItem('user')));
this.currentUser = this.currentUserSubject.asObservable();
}
public get currentUserValue(): User {
return this.currentUserSubject.value;
}
login (data): Observable<any> {
// console.log(data);
return this.http.post<any>(`${AppConfig.apiUrl}/user/login`, data, httpOptions)
.pipe(map(user => {
console.log(user);
// store user details and jwt token in local storage to keep user logged in between page refreshes
localStorage.setItem('user', JSON.stringify(user));
this.currentUserSubject.next(user);
return user;
}));
}
logout() {
// remove user from local storage to log user out
localStorage.removeItem('user');
this.currentUserSubject.next(null);
}
}
Créons le fichier /src/app/models/user.ts
export class User {
id: number;
username: string;
password: string;
token?: string;
}
Voici à quoi devrait ressembler notre formulaire de connexion à l’affichage
En cas d’erreur après validation
En cas de succès
Sources: https://github.com/rabehasy/angular-electron/tree/step2