feat(frontend): require landlord profile completion
This commit is contained in:
1 parent
983a080cc7
commit
12316b8604
18 files changed
+838
-217
No files matched your search
+30
@@ -0,0 +1,30 @@
|
||||
.profile-dialog-content {
|
||||
display: grid;
|
||||
width: min(36rem, calc(100vw - 3rem));
|
||||
max-width: 100%;
|
||||
gap: var(--space-4, 1rem);
|
||||
max-height: 75vh;
|
||||
overscroll-behavior-y: contain;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.profile-dialog-content > p,
|
||||
.profile-error p {
|
||||
margin: 0;
|
||||
color: var(--text-muted, #687670);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profile-loading,
|
||||
.profile-error {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.profile-error {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<h2 mat-dialog-title>Complétez votre profil</h2>
|
||||
<mat-dialog-content class="profile-dialog-content">
|
||||
<p>Vos coordonnées sont nécessaires pour créer vos baux et vos quittances.</p>
|
||||
|
||||
@if (profileStatus() === 'loading') {
|
||||
<div class="profile-loading" role="status" aria-live="polite">
|
||||
<mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil…
|
||||
</div>
|
||||
} @else if (profileStatus() === 'error') {
|
||||
<div class="profile-error">
|
||||
<p role="alert">{{ error() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="retry()">Réessayer</button>
|
||||
</div>
|
||||
} @else {
|
||||
<app-landlord-profile-form
|
||||
[profile]="profile()"
|
||||
[saving]="saving()"
|
||||
[saveError]="error()"
|
||||
(saveRequested)="save($event)"
|
||||
/>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="logout()" [disabled]="saving()">Déconnexion</button>
|
||||
</mat-dialog-actions>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { LandlordProfileService } from '../../services/landlord-profile.service';
|
||||
import { SessionService } from '../../services/session.service';
|
||||
import {
|
||||
LandlordProfileCompletionDialog,
|
||||
LandlordProfileCompletionDialogData,
|
||||
} from './landlord-profile-completion-dialog';
|
||||
|
||||
describe('LandlordProfileCompletionDialog', () => {
|
||||
const profile = { getProfile: vi.fn(), updateProfile: vi.fn() };
|
||||
const dialogRef = { close: vi.fn() };
|
||||
const session = { logout: vi.fn() };
|
||||
const data: LandlordProfileCompletionDialogData = {
|
||||
profile: {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
data.profile = {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: LandlordProfileService, useValue: profile },
|
||||
{ provide: SessionService, useValue: session },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('saves the completed profile before closing the dialog', () => {
|
||||
profile.updateProfile.mockReturnValue(
|
||||
of({ ...data.profile, phone: '+33600000000', address: '1 rue A', complete: true }),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
component.profile.update((current) => ({
|
||||
...current,
|
||||
phone: '+33600000000',
|
||||
address: '1 rue A',
|
||||
}));
|
||||
|
||||
component.save(component.profile());
|
||||
|
||||
expect(profile.updateProfile).toHaveBeenCalledWith({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue A',
|
||||
});
|
||||
expect(dialogRef.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps the dialog open and explains a rejected save', () => {
|
||||
profile.updateProfile.mockReturnValue(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
error: { message: 'Cette adresse e-mail est déjà utilisée.' },
|
||||
status: 409,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
component.profile.update((current) => ({
|
||||
...current,
|
||||
phone: '+33600000000',
|
||||
address: '1 rue A',
|
||||
}));
|
||||
|
||||
component.save(component.profile());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.');
|
||||
expect(fixture.nativeElement.textContent).toContain('Réessayer');
|
||||
});
|
||||
|
||||
it('retries an initial profile load without releasing the dialog', () => {
|
||||
data.profile = null;
|
||||
profile.getProfile.mockReturnValue(
|
||||
of({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
complete: false,
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.retry();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(profile.getProfile).toHaveBeenCalledOnce();
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('input#profile-phone').value).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the load error visible when a retry fails', () => {
|
||||
data.profile = null;
|
||||
profile.getProfile.mockReturnValue(throwError(() => new Error('offline')));
|
||||
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.retry();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de charger votre profil bailleur. Réessayez.',
|
||||
);
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { LandlordProfileForm } from '../landlord-profile-form/landlord-profile-form';
|
||||
import {
|
||||
LandlordProfile,
|
||||
LandlordProfileResponse,
|
||||
LandlordProfileService,
|
||||
} from '../../services/landlord-profile.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { SessionService } from '../../services/session.service';
|
||||
|
||||
export interface LandlordProfileCompletionDialogData {
|
||||
profile: LandlordProfileResponse | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-landlord-profile-completion-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule, MatProgressSpinnerModule, LandlordProfileForm],
|
||||
templateUrl: './landlord-profile-completion-dialog.html',
|
||||
styleUrl: './landlord-profile-completion-dialog.css',
|
||||
})
|
||||
export class LandlordProfileCompletionDialog {
|
||||
readonly data = inject<LandlordProfileCompletionDialogData>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<LandlordProfileCompletionDialog>);
|
||||
private readonly landlordProfile = inject(LandlordProfileService);
|
||||
private readonly session = inject(SessionService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly profileStatus = signal<'ready' | 'loading' | 'error'>(
|
||||
this.data.profile ? 'ready' : 'error',
|
||||
);
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal(
|
||||
this.data.profile ? '' : 'Impossible de charger votre profil bailleur. Réessayez.',
|
||||
);
|
||||
readonly profile = signal<LandlordProfile>(this.profileFields(this.data.profile));
|
||||
|
||||
retry(): void {
|
||||
this.profileStatus.set('loading');
|
||||
this.error.set('');
|
||||
this.landlordProfile
|
||||
.getProfile()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (profile) => {
|
||||
if (profile.complete) {
|
||||
this.dialogRef.close();
|
||||
return;
|
||||
}
|
||||
this.profile.set(this.profileFields(profile));
|
||||
this.profileStatus.set('ready');
|
||||
},
|
||||
error: () => {
|
||||
this.profileStatus.set('error');
|
||||
this.error.set('Impossible de charger votre profil bailleur. Réessayez.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
save(profile: LandlordProfile): void {
|
||||
if (this.saving()) return;
|
||||
this.error.set('');
|
||||
this.saving.set(true);
|
||||
this.landlordProfile
|
||||
.updateProfile(profile)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.dialogRef.close(),
|
||||
error: (error: unknown) => {
|
||||
this.error.set(
|
||||
httpErrorMessage(error, 'Impossible d’enregistrer votre profil bailleur. Réessayez.'),
|
||||
);
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.session.logout();
|
||||
}
|
||||
|
||||
private profileFields(profile: LandlordProfile | null): LandlordProfile {
|
||||
return {
|
||||
firstName: profile?.firstName ?? '',
|
||||
lastName: profile?.lastName ?? '',
|
||||
email: profile?.email ?? '',
|
||||
phone: profile?.phone ?? '',
|
||||
address: profile?.address ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
:host {
|
||||
display: block;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.profile-error {
|
||||
margin: 0;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.profile-fields {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
gap: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
|
||||
}
|
||||
|
||||
.profile-fields mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@container (min-width: 32rem) {
|
||||
.profile-fields {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.profile-fields .full-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<form
|
||||
class="profile-form"
|
||||
[formRoot]="profileForm"
|
||||
(ngSubmit)="submit()"
|
||||
[attr.aria-busy]="saving()"
|
||||
>
|
||||
@if (error()) {
|
||||
<p class="profile-error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
<fieldset class="profile-fields" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Prénom</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-firstName"
|
||||
[formField]="profileForm.firstName"
|
||||
autocomplete="given-name"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Nom</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-lastName"
|
||||
[formField]="profileForm.lastName"
|
||||
autocomplete="family-name"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>E-mail</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-email"
|
||||
type="email"
|
||||
[formField]="profileForm.email"
|
||||
autocomplete="email"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline"
|
||||
><mat-label>Téléphone</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-phone"
|
||||
type="tel"
|
||||
[formField]="profileForm.phone"
|
||||
autocomplete="tel"
|
||||
/></mat-form-field>
|
||||
<mat-form-field appearance="outline" class="full-field"
|
||||
><mat-label>Adresse postale</mat-label
|
||||
><input
|
||||
matInput
|
||||
id="profile-address"
|
||||
[formField]="profileForm.address"
|
||||
autocomplete="street-address"
|
||||
/></mat-form-field>
|
||||
</fieldset>
|
||||
<div class="profile-actions">
|
||||
<button mat-flat-button type="submit" class="primary-action">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
|
||||
} @else if (error()) {
|
||||
Réessayer
|
||||
} @else {
|
||||
Enregistrer le profil
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { LandlordProfileForm } from './landlord-profile-form';
|
||||
|
||||
describe('LandlordProfileForm', () => {
|
||||
it('explains incomplete profile data without requesting a save', () => {
|
||||
const fixture = TestBed.createComponent(LandlordProfileForm);
|
||||
const component = fixture.componentInstance;
|
||||
const saveRequested = vi.fn();
|
||||
component.saveRequested.subscribe(saveRequested);
|
||||
fixture.componentRef.setInput('profile', {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
component.submit();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(saveRequested).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Veuillez renseigner tous les champs obligatoires.',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a complete profile for saving', () => {
|
||||
const fixture = TestBed.createComponent(LandlordProfileForm);
|
||||
const component = fixture.componentInstance;
|
||||
const saveRequested = vi.fn();
|
||||
component.saveRequested.subscribe(saveRequested);
|
||||
fixture.componentRef.setInput('profile', {
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue de Paris',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
component.submit();
|
||||
|
||||
expect(saveRequested).toHaveBeenCalledWith({
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '+33600000000',
|
||||
address: '1 rue de Paris',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, computed, effect, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { email, form, FormField, FormRoot, required } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { LandlordProfile } from '../../services/landlord-profile.service';
|
||||
|
||||
const EMPTY_PROFILE: LandlordProfile = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-landlord-profile-form',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormRoot,
|
||||
FormField,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './landlord-profile-form.html',
|
||||
styleUrl: './landlord-profile-form.css',
|
||||
})
|
||||
export class LandlordProfileForm {
|
||||
readonly profile = input<LandlordProfile>(EMPTY_PROFILE);
|
||||
readonly saving = input(false);
|
||||
readonly saveError = input('');
|
||||
readonly saveRequested = output<LandlordProfile>();
|
||||
|
||||
readonly profileModel = signal<LandlordProfile>({ ...this.profile() });
|
||||
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.' });
|
||||
});
|
||||
private readonly validationError = signal('');
|
||||
readonly error = computed(() => this.validationError() || this.saveError());
|
||||
|
||||
constructor() {
|
||||
effect(() => this.profileModel.set({ ...this.profile() }));
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.saving()) return;
|
||||
|
||||
this.profileForm().markAsTouched();
|
||||
if (this.profileForm().invalid()) {
|
||||
this.validationError.set(
|
||||
this.profileForm().errorSummary()[0]?.message ?? 'Saisissez des informations valides.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.validationError.set('');
|
||||
this.saveRequested.emit(this.profileModel());
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
.profile-message .alert-error,
|
||||
.profile-form .alert-error,
|
||||
.profile-success {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
@@ -98,19 +97,6 @@
|
||||
color: var(--accent-strong, #0a5f59);
|
||||
font-weight: 600;
|
||||
}
|
||||
.profile-fields {
|
||||
display: grid;
|
||||
gap: 4px 12px;
|
||||
}
|
||||
.profile-fields mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.session-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -152,10 +138,4 @@
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
.profile-fields {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.profile-fields .full-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +1,94 @@
|
||||
<section class="page-section settings-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Préférences</p>
|
||||
<h1>Réglages</h1>
|
||||
<p>Adaptez LMNP Organizer à votre façon de travailler.</p>
|
||||
</div>
|
||||
</header>
|
||||
<article class="settings-card" aria-labelledby="appearance-heading">
|
||||
<div class="settings-copy">
|
||||
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faMoon" /></span>
|
||||
<div>
|
||||
<p class="eyebrow">Interface</p>
|
||||
<h2 id="appearance-heading">Apparence</h2>
|
||||
<p>Le réglage est mémorisé uniquement sur cet appareil.</p>
|
||||
</div>
|
||||
</div>
|
||||
<mat-radio-group class="theme-options" aria-label="Thème de l'application" [value]="theme.preference()"
|
||||
(change)="theme.setPreference($event.value)">
|
||||
<mat-radio-button value="light"><span class="theme-option-copy"><strong>Clair</strong><small>Une interface
|
||||
lumineuse et douce.</small></span></mat-radio-button>
|
||||
<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="system"><span class="theme-option-copy"><strong>Système</strong><small>Suit
|
||||
automatiquement votre appareil.</small></span></mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</article>
|
||||
<article class="settings-card landlord-profile" aria-labelledby="profile-heading">
|
||||
<div class="settings-copy">
|
||||
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faAddressCard" /></span>
|
||||
<div>
|
||||
<p class="eyebrow">Bailleur</p>
|
||||
<h2 id="profile-heading">Profil personnel</h2>
|
||||
<p>Ces coordonnées sont utilisées dans vos baux et vos quittances.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-content">
|
||||
@if (profileStatus() === 'loading') {
|
||||
<div class="profile-loading" role="status" aria-live="polite">
|
||||
<mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil…
|
||||
</div>
|
||||
} @else if (profileStatus() === 'error') {
|
||||
<div class="profile-message">
|
||||
<p class="alert-error" role="alert">{{ profileError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="loadProfile()">Réessayer</button>
|
||||
</div>
|
||||
} @else {
|
||||
<form class="profile-form" [formRoot]="profileForm" (ngSubmit)="save()" [attr.aria-busy]="saving()">
|
||||
@if (saveError()) {
|
||||
<p class="alert-error" role="alert">{{ saveError() }}</p>
|
||||
}
|
||||
@if (saveSuccess()) {
|
||||
<p class="profile-success" role="status">{{ saveSuccess() }}</p>
|
||||
}
|
||||
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
|
||||
<div class="profile-fields">
|
||||
<mat-form-field appearance="outline"><mat-label>Prénom</mat-label><input matInput id="firstName"
|
||||
[formField]="profileForm.firstName" autocomplete="given-name" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>Nom</mat-label><input matInput id="lastName"
|
||||
[formField]="profileForm.lastName" autocomplete="family-name" /></mat-form-field>
|
||||
<mat-form-field appearance="outline"><mat-label>E-mail</mat-label><input matInput type="email"
|
||||
id="email" [formField]="profileForm.email" autocomplete="email" /></mat-form-field>
|
||||
<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>
|
||||
<mat-form-field appearance="outline" class="full-field"><mat-label>Adresse
|
||||
postale</mat-label><input matInput id="address" [formField]="profileForm.address"
|
||||
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>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Préférences</p>
|
||||
<h1>Réglages</h1>
|
||||
<p>Adaptez LMNP Organizer à votre façon de travailler.</p>
|
||||
</div>
|
||||
</header>
|
||||
<article class="settings-card" aria-labelledby="appearance-heading">
|
||||
<div class="settings-copy">
|
||||
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faMoon" /></span>
|
||||
<div>
|
||||
<p class="eyebrow">Interface</p>
|
||||
<h2 id="appearance-heading">Apparence</h2>
|
||||
<p>Le réglage est mémorisé uniquement sur cet appareil.</p>
|
||||
</div>
|
||||
</div>
|
||||
<mat-radio-group
|
||||
class="theme-options"
|
||||
aria-label="Thème de l'application"
|
||||
[value]="theme.preference()"
|
||||
(change)="theme.setPreference($event.value)"
|
||||
>
|
||||
<mat-radio-button value="light"
|
||||
><span class="theme-option-copy"
|
||||
><strong>Clair</strong><small>Une interface lumineuse et douce.</small></span
|
||||
></mat-radio-button
|
||||
>
|
||||
<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="system"
|
||||
><span class="theme-option-copy"
|
||||
><strong>Système</strong><small>Suit automatiquement votre appareil.</small></span
|
||||
></mat-radio-button
|
||||
>
|
||||
</mat-radio-group>
|
||||
</article>
|
||||
<article class="settings-card landlord-profile" aria-labelledby="profile-heading">
|
||||
<div class="settings-copy">
|
||||
<span class="settings-icon" aria-hidden="true"><fa-icon [icon]="faAddressCard" /></span>
|
||||
<div>
|
||||
<p class="eyebrow">Bailleur</p>
|
||||
<h2 id="profile-heading">Profil personnel</h2>
|
||||
<p>Ces coordonnées sont utilisées dans vos baux et vos quittances.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-content">
|
||||
@if (profileStatus() === 'loading') {
|
||||
<div class="profile-loading" role="status" aria-live="polite">
|
||||
<mat-spinner diameter="24" aria-hidden="true" /> Chargement de votre profil…
|
||||
</div>
|
||||
} @else if (profileStatus() === 'error') {
|
||||
<div class="profile-message">
|
||||
<p class="alert-error" role="alert">{{ profileError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="loadProfile()">Réessayer</button>
|
||||
</div>
|
||||
} @else {
|
||||
@if (saveSuccess()) {
|
||||
<p class="profile-success" role="status">{{ saveSuccess() }}</p>
|
||||
}
|
||||
<app-landlord-profile-form
|
||||
[profile]="profileModel()"
|
||||
[saving]="saving()"
|
||||
[saveError]="saveError()"
|
||||
(saveRequested)="save($event)"
|
||||
/>
|
||||
}
|
||||
</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>
|
||||
@@ -48,24 +48,13 @@ describe('SettingsPage', () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('input#firstName').value).toBe('Camille');
|
||||
expect(fixture.nativeElement.querySelector('input#email').value).toBe('camille@example.com');
|
||||
expect(fixture.nativeElement.querySelector('input#address').value).toBe('1 rue de Paris');
|
||||
});
|
||||
|
||||
it('shows a useful error instead of saving an incomplete profile', () => {
|
||||
const fixture = TestBed.createComponent(SettingsPage);
|
||||
fixture.detectChanges();
|
||||
const component = fixture.componentInstance;
|
||||
component.profileModel.update((p) => ({ ...p, phone: '' }));
|
||||
|
||||
component.save();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Veuillez renseigner tous les champs obligatoires.',
|
||||
expect(fixture.nativeElement.querySelector('input#profile-firstName').value).toBe('Camille');
|
||||
expect(fixture.nativeElement.querySelector('input#profile-email').value).toBe(
|
||||
'camille@example.com',
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('input#profile-address').value).toBe(
|
||||
'1 rue de Paris',
|
||||
);
|
||||
expect(profile.updateProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the entered values and explains a rejected profile save', async () => {
|
||||
@@ -80,10 +69,13 @@ describe('SettingsPage', () => {
|
||||
);
|
||||
const fixture = TestBed.createComponent(SettingsPage);
|
||||
fixture.detectChanges();
|
||||
const component = fixture.componentInstance;
|
||||
component.profileModel.update((p) => ({ ...p, email: 'alex@example.com' }));
|
||||
await fixture.whenStable();
|
||||
const email = fixture.nativeElement.querySelector('input#profile-email') as HTMLInputElement;
|
||||
email.value = 'alex@example.com';
|
||||
email.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
|
||||
component.save();
|
||||
fixture.nativeElement.querySelector('button.primary-action').click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
@@ -95,6 +87,8 @@ describe('SettingsPage', () => {
|
||||
address: '1 rue de Paris',
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Cette adresse e-mail est déjà utilisée.');
|
||||
expect(fixture.nativeElement.querySelector('input#email').value).toBe('alex@example.com');
|
||||
expect(fixture.nativeElement.querySelector('input#profile-email').value).toBe(
|
||||
'alex@example.com',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { faAddressCard, faMoon, faUser } from '@fortawesome/free-solid-svg-icons';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormRoot, FormField, form, required, email } from '@angular/forms/signals';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { LandlordProfileForm } from '../landlord-profile-form/landlord-profile-form';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { LandlordProfile, LandlordProfileService } from '../../services/landlord-profile.service';
|
||||
import { SessionService } from '../../services/session.service';
|
||||
import { ThemeService } from '../../services/theme.service';
|
||||
@@ -17,14 +15,10 @@ import { ThemeService } from '../../services/theme.service';
|
||||
selector: 'app-settings-page',
|
||||
imports: [
|
||||
FontAwesomeModule,
|
||||
FormsModule,
|
||||
FormRoot,
|
||||
FormField,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatRadioModule,
|
||||
LandlordProfileForm,
|
||||
],
|
||||
templateUrl: './settings-page.html',
|
||||
styleUrl: './settings-page.css',
|
||||
@@ -36,6 +30,7 @@ export class SettingsPage {
|
||||
readonly session = inject(SessionService);
|
||||
readonly theme = inject(ThemeService);
|
||||
private readonly landlordProfile = inject(LandlordProfileService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly profileStatus = signal<'loading' | 'ready' | 'error'>('loading');
|
||||
readonly saving = signal(false);
|
||||
@@ -48,15 +43,6 @@ export class SettingsPage {
|
||||
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 saveError = signal('');
|
||||
readonly saveSuccess = signal('');
|
||||
@@ -72,57 +58,41 @@ export class SettingsPage {
|
||||
loadProfile(): void {
|
||||
this.profileStatus.set('loading');
|
||||
this.profileError.set('');
|
||||
this.landlordProfile.getProfile().subscribe({
|
||||
next: (profile) => {
|
||||
this.profileModel.set(profile);
|
||||
this.profileStatus.set('ready');
|
||||
},
|
||||
error: () => {
|
||||
this.profileStatus.set('error');
|
||||
this.profileError.set('Impossible de charger votre profil bailleur. Réessayez.');
|
||||
},
|
||||
});
|
||||
this.landlordProfile
|
||||
.getProfile()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (profile) => {
|
||||
this.profileModel.set(profile);
|
||||
this.profileStatus.set('ready');
|
||||
},
|
||||
error: () => {
|
||||
this.profileStatus.set('error');
|
||||
this.profileError.set('Impossible de charger votre profil bailleur. Réessayez.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
save(profile: LandlordProfile): void {
|
||||
if (this.saving()) return;
|
||||
this.saveSuccess.set('');
|
||||
|
||||
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.landlordProfile.updateProfile(this.profileModel()).subscribe({
|
||||
next: (savedProfile) => {
|
||||
this.profileModel.set(savedProfile);
|
||||
this.saveSuccess.set('Profil bailleur enregistré.');
|
||||
this.saving.set(false);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.saveError.set(this.saveErrorMessage(error));
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private saveErrorMessage(error: unknown): string {
|
||||
if (error instanceof HttpErrorResponse && this.hasMessage(error.error)) {
|
||||
return error.error.message;
|
||||
}
|
||||
return 'Impossible d’enregistrer votre profil bailleur. Réessayez.';
|
||||
}
|
||||
|
||||
private hasMessage(value: unknown): value is { message: string } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'message' in value &&
|
||||
typeof value.message === 'string'
|
||||
);
|
||||
this.landlordProfile
|
||||
.updateProfile(profile)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (savedProfile) => {
|
||||
this.profileModel.set(savedProfile);
|
||||
this.saveSuccess.set('Profil bailleur enregistré.');
|
||||
this.saving.set(false);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.saveError.set(
|
||||
httpErrorMessage(error, 'Impossible d’enregistrer votre profil bailleur. Réessayez.'),
|
||||
);
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user