feat: refactor settings page to use reactive forms for landlord profile

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent b76dd27431
commit 5d2abf4184
4 files changed
+135 -218

No files matched your search

-17
View File
@@ -1,17 +0,0 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
@@ -1,158 +1,99 @@
<section class="page-section settings-page"> <section class="page-section settings-page">
<header class="page-heading"> <header class="page-heading">
<div> <div>
<p class="eyebrow">Préférences</p> <p class="eyebrow">Préférences</p>
<h1>Réglages</h1> <h1>Réglages</h1>
<p>Adaptez LMNP Organizer à votre façon de travailler.</p> <p>Adaptez LMNP Organizer à votre façon de travailler.</p>
</div> </div>
</header> </header>
<article class="settings-card" aria-labelledby="appearance-heading"> <article class="settings-card" aria-labelledby="appearance-heading">
<div class="settings-copy"> <div class="settings-copy">
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faMoon" /></span> <span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faMoon" /></span>
<div> <div>
<p class="eyebrow">Interface</p> <p class="eyebrow">Interface</p>
<h2 id="appearance-heading">Apparence</h2> <h2 id="appearance-heading">Apparence</h2>
<p>Le réglage est mémorisé uniquement sur cet appareil.</p> <p>Le réglage est mémorisé uniquement sur cet appareil.</p>
</div> </div>
</div> </div>
<mat-radio-group <mat-radio-group class="theme-options" aria-label="Thème de l'application" [value]="theme.preference()"
class="theme-options" (change)="theme.setPreference($event.value)">
aria-label="Thème de l'application" <mat-radio-button value="light"><span class="theme-option-copy"><strong>Clair</strong><small>Une interface
[value]="theme.preference()" lumineuse et douce.</small></span></mat-radio-button>
(change)="theme.setPreference($event.value)" <mat-radio-button value="dark"><span class="theme-option-copy"><strong>Sombre</strong><small>Un contraste
> réduit en faible lumière.</small></span></mat-radio-button>
<mat-radio-button value="light" <mat-radio-button value="system"><span class="theme-option-copy"><strong>Système</strong><small>Suit
><span class="theme-option-copy" automatiquement votre appareil.</small></span></mat-radio-button>
><strong>Clair</strong><small>Une interface lumineuse et douce.</small></span </mat-radio-group>
></mat-radio-button </article>
> <article class="settings-card landlord-profile" aria-labelledby="profile-heading">
<mat-radio-button value="dark" <div class="settings-copy">
><span class="theme-option-copy" <span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faAddressCard" /></span>
><strong>Sombre</strong><small>Un contraste réduit en faible lumière.</small></span <div>
></mat-radio-button <p class="eyebrow">Bailleur</p>
> <h2 id="profile-heading">Profil personnel</h2>
<mat-radio-button value="system" <p>Ces coordonnées sont utilisées dans vos baux et vos quittances.</p>
><span class="theme-option-copy" </div>
><strong>Système</strong><small>Suit automatiquement votre appareil.</small></span </div>
></mat-radio-button <div class="profile-content">
> @if (profileStatus() === 'loading') {
</mat-radio-group> <div class="profile-loading" role="status" aria-live="polite">
</article> <mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil…
<article class="settings-card landlord-profile" aria-labelledby="profile-heading"> </div>
<div class="settings-copy"> } @else if (profileStatus() === 'error') {
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faAddressCard" /></span> <div class="profile-message">
<div> <p class="alert-error" role="alert">{{ profileError() }}</p>
<p class="eyebrow">Bailleur</p> <button mat-stroked-button type="button" (click)="loadProfile()">Réessayer</button>
<h2 id="profile-heading">Profil personnel</h2> </div>
<p>Ces coordonnées sont utilisées dans vos baux et vos quittances.</p> } @else {
</div> <form class="profile-form" [formRoot]="profileForm" (ngSubmit)="save()" [attr.aria-busy]="saving()">
</div> @if (saveError()) {
<div class="profile-content"> <p class="alert-error" role="alert">{{ saveError() }}</p>
@if (profileStatus() === 'loading') { }
<div class="profile-loading" role="status" aria-live="polite"> @if (saveSuccess()) {
<mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil… <p class="profile-success" role="status">{{ saveSuccess() }}</p>
</div> }
} @else if (profileStatus() === 'error') { <fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<div class="profile-message"> <div class="profile-fields">
<p class="alert-error" role="alert">{{ profileError() }}</p> <mat-form-field appearance="outline"><mat-label>Prénom</mat-label><input matInput id="firstName"
<button mat-stroked-button type="button" (click)="loadProfile()">Réessayer</button> [formField]="profileForm.firstName" autocomplete="given-name" /></mat-form-field>
</div> <mat-form-field appearance="outline"><mat-label>Nom</mat-label><input matInput id="lastName"
} @else { [formField]="profileForm.lastName" autocomplete="family-name" /></mat-form-field>
<form class="profile-form" (ngSubmit)="save()" [attr.aria-busy]="saving()"> <mat-form-field appearance="outline"><mat-label>E-mail</mat-label><input matInput type="email"
@if (saveError()) { id="email" [formField]="profileForm.email" autocomplete="email" /></mat-form-field>
<p class="alert-error" role="alert">{{ saveError() }}</p> <mat-form-field appearance="outline"><mat-label>Téléphone</mat-label><input matInput type="tel"
} id="phone" [formField]="profileForm.phone" autocomplete="tel" /></mat-form-field>
@if (saveSuccess()) { <mat-form-field appearance="outline" class="full-field"><mat-label>Adresse
<p class="profile-success" role="status">{{ saveSuccess() }}</p> postale</mat-label><input matInput id="address" [formField]="profileForm.address"
} autocomplete="street-address" /></mat-form-field>
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null"> </div>
<div class="profile-fields"> <div class="profile-actions">
<mat-form-field appearance="outline" <button mat-flat-button type="submit" class="primary-action">
><mat-label>Prénom</mat-label @if (saving()) {
><input <mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
matInput } @else {
name="firstName" Enregistrer le profil
[ngModel]="firstName()" }
(ngModelChange)="firstName.set($event)" </button>
required </div>
autocomplete="given-name" </fieldset>
/></mat-form-field> </form>
<mat-form-field appearance="outline" }
><mat-label>Nom</mat-label </div>
><input </article>
matInput <article class="settings-card session-settings" data-testid="session-settings" aria-labelledby="session-heading">
name="lastName" <div class="settings-copy">
[ngModel]="lastName()" <span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faUser" /></span>
(ngModelChange)="lastName.set($event)" <div>
required <p class="eyebrow">Compte</p>
autocomplete="family-name" <h2 id="session-heading">Session</h2>
/></mat-form-field> <p>Gérez la session ouverte sur cet appareil.</p>
<mat-form-field appearance="outline" </div>
><mat-label>E-mail</mat-label </div>
><input <div class="session-action">
matInput <div>
type="email" <strong>{{ session.username() }}</strong><span>{{ session.email() }}</span>
name="email" </div>
[ngModel]="email()" <button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
(ngModelChange)="email.set($event)" </div>
required </article>
autocomplete="email"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone</mat-label
><input
matInput
type="tel"
name="phone"
[ngModel]="phone()"
(ngModelChange)="phone.set($event)"
required
autocomplete="tel"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse postale</mat-label
><input
matInput
name="address"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
required
autocomplete="street-address"
/></mat-form-field>
</div>
<div class="profile-actions">
<button mat-flat-button type="submit" class="primary-action">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer le profil
}
</button>
</div>
</fieldset>
</form>
}
</div>
</article>
<article
class="settings-card session-settings"
data-testid="session-settings"
aria-labelledby="session-heading"
>
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faUser" /></span>
<div>
<p class="eyebrow">Compte</p>
<h2 id="session-heading">Session</h2>
<p>Gérez la session ouverte sur cet appareil.</p>
</div>
</div>
<div class="session-action">
<div>
<strong>{{ session.username() }}</strong
><span>{{ session.email() }}</span>
</div>
<button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
</div>
</article>
</section> </section>
@@ -47,18 +47,18 @@ describe('SettingsPage', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(fixture.nativeElement.querySelector('input[name=firstName]').value).toBe('Camille'); expect(fixture.nativeElement.querySelector('input#firstName').value).toBe('Camille');
expect(fixture.nativeElement.querySelector('input[name=email]').value).toBe( expect(fixture.nativeElement.querySelector('input#email').value).toBe(
'camille@example.com', 'camille@example.com',
); );
expect(fixture.nativeElement.querySelector('input[name=address]').value).toBe('1 rue de Paris'); expect(fixture.nativeElement.querySelector('input#address').value).toBe('1 rue de Paris');
}); });
it('shows a useful error instead of saving an incomplete profile', () => { it('shows a useful error instead of saving an incomplete profile', () => {
const fixture = TestBed.createComponent(SettingsPage); const fixture = TestBed.createComponent(SettingsPage);
fixture.detectChanges(); fixture.detectChanges();
const component = fixture.componentInstance; const component = fixture.componentInstance;
component.phone.set(''); component.profileModel.update((p) => ({ ...p, phone: '' }));
component.save(); component.save();
fixture.detectChanges(); fixture.detectChanges();
@@ -82,7 +82,7 @@ describe('SettingsPage', () => {
const fixture = TestBed.createComponent(SettingsPage); const fixture = TestBed.createComponent(SettingsPage);
fixture.detectChanges(); fixture.detectChanges();
const component = fixture.componentInstance; const component = fixture.componentInstance;
component.email.set('alex@example.com'); component.profileModel.update((p) => ({ ...p, email: 'alex@example.com' }));
component.save(); component.save();
fixture.detectChanges(); fixture.detectChanges();
@@ -96,6 +96,6 @@ describe('SettingsPage', () => {
address: '1 rue de Paris', address: '1 rue de Paris',
}); });
expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.'); expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.');
expect(fixture.nativeElement.querySelector('input[name=email]').value).toBe('alex@example.com'); expect(fixture.nativeElement.querySelector('input#email').value).toBe('alex@example.com');
}); });
}); });
@@ -7,6 +7,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormRoot, FormField, form, required, email } from '@angular/forms/signals';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { LandlordProfile, LandlordProfileService } from '../../services/landlord-profile.service'; import { LandlordProfile, LandlordProfileService } from '../../services/landlord-profile.service';
import { SessionService } from '../../services/session.service'; import { SessionService } from '../../services/session.service';
@@ -17,6 +18,8 @@ import { ThemeService } from '../../services/theme.service';
imports: [ imports: [
FontAwesomeModule, FontAwesomeModule,
FormsModule, FormsModule,
FormRoot,
FormField,
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
@@ -36,11 +39,24 @@ export class SettingsPage {
readonly profileStatus = signal<'loading' | 'ready' | 'error'>('loading'); readonly profileStatus = signal<'loading' | 'ready' | 'error'>('loading');
readonly saving = signal(false); readonly saving = signal(false);
readonly firstName = signal('');
readonly lastName = signal(''); readonly profileModel = signal<LandlordProfile>({
readonly email = signal(''); firstName: '',
readonly phone = signal(''); lastName: '',
readonly address = signal(''); email: '',
phone: '',
address: '',
});
readonly profileForm = form(this.profileModel, (f) => {
required(f.firstName, { message: 'Veuillez renseigner tous les champs obligatoires.' });
required(f.lastName, { message: 'Veuillez renseigner tous les champs obligatoires.' });
required(f.email, { message: 'Veuillez renseigner tous les champs obligatoires.' });
required(f.phone, { message: 'Veuillez renseigner tous les champs obligatoires.' });
required(f.address, { message: 'Veuillez renseigner tous les champs obligatoires.' });
email(f.email, { message: 'Saisissez une adresse e-mail valide.' });
});
readonly profileError = signal(''); readonly profileError = signal('');
readonly saveError = signal(''); readonly saveError = signal('');
readonly saveSuccess = signal(''); readonly saveSuccess = signal('');
@@ -58,7 +74,7 @@ export class SettingsPage {
this.profileError.set(''); this.profileError.set('');
this.landlordProfile.getProfile().subscribe({ this.landlordProfile.getProfile().subscribe({
next: (profile) => { next: (profile) => {
this.applyProfile(profile); this.profileModel.set(profile);
this.profileStatus.set('ready'); this.profileStatus.set('ready');
}, },
error: () => { error: () => {
@@ -70,21 +86,20 @@ export class SettingsPage {
save(): void { save(): void {
if (this.saving()) return; if (this.saving()) return;
const profile = {
firstName: this.firstName().trim(),
lastName: this.lastName().trim(),
email: this.email().trim(),
phone: this.phone().trim(),
address: this.address().trim(),
};
this.saveSuccess.set(''); this.saveSuccess.set('');
this.saveError.set(this.validationError(profile));
if (this.saveError()) return;
this.profileForm().markAsTouched();
if (this.profileForm().invalid()) {
const errors = this.profileForm().errorSummary();
this.saveError.set(errors[0]?.message ?? 'Saisissez des informations valides.');
return;
}
this.saveError.set('');
this.saving.set(true); this.saving.set(true);
this.landlordProfile.updateProfile(profile).subscribe({ this.landlordProfile.updateProfile(this.profileModel()).subscribe({
next: (savedProfile) => { next: (savedProfile) => {
this.applyProfile(savedProfile); this.profileModel.set(savedProfile);
this.saveSuccess.set('Profil bailleur enregistré.'); this.saveSuccess.set('Profil bailleur enregistré.');
this.saving.set(false); this.saving.set(false);
}, },
@@ -95,28 +110,6 @@ export class SettingsPage {
}); });
} }
private validationError(profile: LandlordProfile): string {
if (Object.values(profile).some((value) => !value)) {
return 'Veuillez renseigner tous les champs obligatoires.';
}
if (
!profile.email.includes('@') ||
profile.email.startsWith('@') ||
profile.email.endsWith('@')
) {
return 'Saisissez une adresse e-mail valide.';
}
return '';
}
private applyProfile(profile: LandlordProfile): void {
this.firstName.set(profile.firstName);
this.lastName.set(profile.lastName);
this.email.set(profile.email);
this.phone.set(profile.phone);
this.address.set(profile.address);
}
private saveErrorMessage(error: unknown): string { private saveErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse && this.hasMessage(error.error)) { if (error instanceof HttpErrorResponse && this.hasMessage(error.error)) {
return error.error.message; return error.error.message;