feat: refactor settings page to use reactive forms for landlord profile
This commit is contained in:
1 parent
b76dd27431
commit
5d2abf4184
4 files changed
+136
-219
No files matched your search
@@ -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"
|
</section>
|
||||||
/></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>
|
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user