Align landlord profile validation (#47)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent d1cdc6eaeb
commit 66fa95dabd
13 files changed
+462 -32

No files matched your search

@@ -17,6 +17,8 @@
[profile]="profile()"
[saving]="saving()"
[saveError]="error()"
[saveFieldErrors]="saveFieldErrors()"
(fieldChanged)="clearSaveFieldError($event)"
(saveRequested)="save($event)"
>
<button
@@ -101,6 +101,43 @@ describe('LandlordProfileCompletionDialog', () => {
expect(fixture.nativeElement.textContent).toContain('Réessayer');
});
it('keeps a structured save error on the field and clears it when corrected', () => {
profile.updateProfile.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
error: {
code: 'validation_failed',
errors: [{ field: 'phoneValid', code: 'assert_true' }],
},
status: 400,
}),
),
);
const fixture = TestBed.createComponent(LandlordProfileCompletionDialog);
const component = fixture.componentInstance;
fixture.detectChanges();
component.save({
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '1234',
address: '1 rue A',
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Saisissez un numéro de téléphone valide.',
);
component.clearSaveFieldError('phone');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
'Saisissez un numéro de téléphone valide.',
);
});
it('retries an initial profile load without releasing the dialog', () => {
data.profile = null;
profile.getProfile.mockReturnValue(
@@ -9,7 +9,7 @@ import {
LandlordProfileResponse,
LandlordProfileService,
} from '../../services/landlord-profile.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { FieldValidationError, saveFailure } from '../../services/http-error-message';
import { SessionService } from '../../services/session.service';
export interface LandlordProfileCompletionDialogData {
@@ -33,6 +33,7 @@ export class LandlordProfileCompletionDialog {
this.data.profile ? 'ready' : 'error',
);
readonly saving = signal(false);
readonly saveFieldErrors = signal<FieldValidationError[]>([]);
readonly error = signal(
this.data.profile ? '' : 'Impossible de charger votre profil bailleur. Réessayez.',
);
@@ -63,6 +64,7 @@ export class LandlordProfileCompletionDialog {
save(profile: LandlordProfile): void {
if (this.saving()) return;
this.error.set('');
this.saveFieldErrors.set([]);
this.saving.set(true);
this.landlordProfile
.updateProfile(profile)
@@ -70,17 +72,24 @@ export class LandlordProfileCompletionDialog {
.subscribe({
next: () => this.dialogRef.close(),
error: (error: unknown) => {
this.error.set(
httpErrorMessage(
error,
'Impossible d’enregistrer votre profil bailleur. Réessayez.',
),
const failure = saveFailure(
error,
'Impossible d’enregistrer votre profil bailleur. Réessayez.',
);
this.error.set(failure.message);
this.saveFieldErrors.set(failure.fieldErrors);
this.saving.set(false);
},
});
}
clearSaveFieldError(field: string): void {
const remaining = this.saveFieldErrors().filter((error) => error.field !== field);
if (remaining.length === this.saveFieldErrors().length) return;
this.saveFieldErrors.set(remaining);
if (!remaining.length) this.error.set('');
}
logout(): void {
this.session.logout();
}
@@ -4,8 +4,11 @@
(ngSubmit)="submit()"
[attr.aria-busy]="saving()"
>
@if (error()) {
<p class="profile-error" role="alert">{{ error() }}</p>
@if (showSaveError()) {
<p class="profile-error" role="alert">{{ saveError() }}</p>
}
@if (validationError()) {
<p class="profile-error" role="alert">{{ validationError() }}</p>
}
<fieldset class="profile-fields" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<mat-form-field appearance="outline"
@@ -14,16 +17,26 @@
matInput
id="profile-firstName"
[formField]="profileForm.firstName"
(input)="fieldWasChanged('firstName')"
autocomplete="given-name"
/></mat-form-field>
/>
@if (firstNameError()) {
<mat-error>{{ firstNameError() }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom</mat-label
><input
matInput
id="profile-lastName"
[formField]="profileForm.lastName"
(input)="fieldWasChanged('lastName')"
autocomplete="family-name"
/></mat-form-field>
/>
@if (lastNameError()) {
<mat-error>{{ lastNameError() }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail</mat-label
><input
@@ -31,8 +44,13 @@
id="profile-email"
type="email"
[formField]="profileForm.email"
(input)="fieldWasChanged('email')"
autocomplete="email"
/></mat-form-field>
/>
@if (emailError()) {
<mat-error>{{ emailError() }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone</mat-label
><input
@@ -40,23 +58,33 @@
id="profile-phone"
type="tel"
[formField]="profileForm.phone"
(input)="fieldWasChanged('phone')"
autocomplete="tel"
/></mat-form-field>
/>
@if (phoneError()) {
<mat-error>{{ phoneError() }}</mat-error>
}
</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"
(input)="fieldWasChanged('address')"
autocomplete="street-address"
/></mat-form-field>
/>
@if (addressError()) {
<mat-error>{{ addressError() }}</mat-error>
}
</mat-form-field>
</fieldset>
<div class="profile-actions">
<ng-content select="[profile-form-secondary-action]" />
<button mat-flat-button type="submit" class="primary-action">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else if (error()) {
} @else if (showSaveError()) {
Réessayer
} @else {
Enregistrer le profil
@@ -50,4 +50,68 @@ describe('LandlordProfileForm', () => {
address: '1 rue de Paris',
});
});
it('focuses and explains the first invalid field after submission', async () => {
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: '1234',
address: '1 rue de Paris',
});
fixture.detectChanges();
component.submit();
await fixture.whenStable();
fixture.detectChanges();
expect(saveRequested).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain(
'Saisissez un numéro de téléphone valide.',
);
expect(document.activeElement?.id).toBe('profile-phone');
});
it('renders and focuses known server errors inline, then clears only the corrected field error', async () => {
const fixture = TestBed.createComponent(LandlordProfileForm);
const component = fixture.componentInstance;
const changed = vi.fn();
component.fieldChanged.subscribe(changed);
fixture.componentRef.setInput('profile', {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '1234',
address: '1 rue de Paris',
});
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'phoneValid', code: 'assert_true' },
]);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain(
'Saisissez un numéro de téléphone valide.',
);
expect(fixture.nativeElement.textContent).not.toContain(
'Veuillez corriger les champs signalés.',
);
expect(document.activeElement?.id).toBe('profile-phone');
const phone = fixture.nativeElement.querySelector(
'input#profile-phone',
) as HTMLInputElement;
phone.dispatchEvent(new Event('input'));
expect(changed).toHaveBeenCalledWith('phone');
fixture.componentRef.setInput('saveFieldErrors', [{ field: 'email', code: 'email' }]);
fixture.detectChanges();
await fixture.whenStable();
expect(document.activeElement?.id).toBe('profile-email');
});
});
@@ -1,11 +1,13 @@
import { Component, computed, effect, input, output, signal } from '@angular/core';
import { Component, effect, ElementRef, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { email, form, FormField, FormRoot, required } from '@angular/forms/signals';
import { email, form, FormField, FormRoot, required, validate } 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 { FieldValidationError } from '../../services/http-error-message';
import { LandlordProfile } from '../../services/landlord-profile.service';
import { isValidPhoneNumber } from '../../services/phone-number';
const EMPTY_PROFILE: LandlordProfile = {
firstName: '',
@@ -30,40 +32,181 @@ const EMPTY_PROFILE: LandlordProfile = {
styleUrl: './landlord-profile-form.css',
})
export class LandlordProfileForm {
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);
readonly profile = input<LandlordProfile>(EMPTY_PROFILE);
readonly saving = input(false);
readonly saveError = input('');
readonly saveFieldErrors = input<FieldValidationError[]>([]);
readonly saveRequested = output<LandlordProfile>();
readonly fieldChanged = output<keyof LandlordProfile>();
private previousServerErrorSignature = '';
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.' });
required(f.firstName, { message: 'Le prénom est obligatoire.' });
required(f.lastName, { message: 'Le nom est obligatoire.' });
required(f.email, { message: 'L’e-mail est obligatoire.' });
required(f.phone, { message: 'Le numéro de téléphone est obligatoire.' });
required(f.address, { message: 'L’adresse postale est obligatoire.' });
email(f.email, { message: 'Saisissez une adresse e-mail valide.' });
validate(f.phone, ({ value }) =>
isValidPhoneNumber(value())
? null
: { kind: 'phone', message: 'Saisissez un numéro de téléphone valide.' },
);
});
private readonly validationError = signal('');
readonly error = computed(() => this.validationError() || this.saveError());
readonly validationError = signal('');
constructor() {
effect(() => this.profileModel.set({ ...this.profile() }));
effect(() => {
this.profileModel.set({ ...this.profile() });
this.validationError.set('');
});
effect(() => {
const errors = this.saveFieldErrors();
const signature = serverErrorSignature(errors);
if (signature && signature !== this.previousServerErrorSignature) {
queueMicrotask(() => this.focusFirstInvalid());
}
this.previousServerErrorSignature = signature;
});
}
submit(): void {
if (this.saving()) return;
this.profileForm().markAsTouched();
if (this.profileForm().invalid()) {
if (this.profileForm().invalid() || this.hasClientError()) {
this.validationError.set(
this.profileForm().errorSummary()[0]?.message ??
this.firstError() ??
'Saisissez des informations valides.',
);
queueMicrotask(() => this.focusFirstInvalid());
return;
}
this.validationError.set('');
this.saveRequested.emit(this.profileModel());
}
fieldWasChanged(field: keyof LandlordProfile): void {
this.validationError.set('');
this.fieldChanged.emit(field);
}
showSaveError(): boolean {
return (
Boolean(this.saveError()) &&
(this.saveFieldErrors().length === 0 ||
this.saveFieldErrors().some((error) => !isInlineServerError(error)))
);
}
firstNameError(): string {
return this.requiredError('firstName', 'Le prénom est obligatoire.');
}
lastNameError(): string {
return this.requiredError('lastName', 'Le nom est obligatoire.');
}
emailError(): string {
if (this.profileForm.email().touched() && !this.profileModel().email.trim()) {
return 'L’e-mail est obligatoire.';
}
if (this.profileForm.email().touched() && this.profileForm.email().invalid()) {
return 'Saisissez une adresse e-mail valide.';
}
return this.hasServerError('email', 'not_blank')
? 'L’e-mail est obligatoire.'
: this.hasServerError('email', 'email')
? 'Saisissez une adresse e-mail valide.'
: '';
}
phoneError(): string {
if (this.profileForm.phone().touched() && !this.profileModel().phone.trim()) {
return 'Le numéro de téléphone est obligatoire.';
}
if (this.profileForm.phone().touched() && !isValidPhoneNumber(this.profileModel().phone)) {
return 'Saisissez un numéro de téléphone valide.';
}
return this.hasServerError('phone', 'not_blank')
? 'Le numéro de téléphone est obligatoire.'
: this.hasServerError('phoneValid', 'assert_true')
? 'Saisissez un numéro de téléphone valide.'
: '';
}
addressError(): string {
return this.requiredError('address', 'L’adresse postale est obligatoire.');
}
private requiredError(field: 'firstName' | 'lastName' | 'address', message: string): string {
if (this.touched(field) && !this.profileModel()[field].trim()) return message;
return this.hasServerError(field, 'not_blank') ? message : '';
}
private touched(field: 'firstName' | 'lastName' | 'address'): boolean {
return this.profileForm[field]().touched();
}
private hasClientError(): boolean {
return Boolean(
this.firstNameError() ||
this.lastNameError() ||
this.emailError() ||
this.phoneError() ||
this.addressError(),
);
}
private firstError(): string {
return (
this.firstNameError() ||
this.lastNameError() ||
this.emailError() ||
this.phoneError() ||
this.addressError()
);
}
private hasServerError(field: string, code: string): boolean {
return this.saveFieldErrors().some((error) => error.field === field && error.code === code);
}
private focusFirstInvalid(): void {
if (!this.element.nativeElement.querySelector('form')) return;
const selector = this.firstNameError()
? '#profile-firstName'
: this.lastNameError()
? '#profile-lastName'
: this.emailError()
? '#profile-email'
: this.phoneError()
? '#profile-phone'
: this.addressError()
? '#profile-address'
: '';
const control = this.element.nativeElement.querySelector(selector);
if (control instanceof HTMLElement) control.focus();
}
}
function isInlineServerError(error: FieldValidationError): boolean {
return (
((error.field === 'firstName' || error.field === 'lastName' || error.field === 'address') &&
error.code === 'not_blank') ||
(error.field === 'email' && (error.code === 'not_blank' || error.code === 'email')) ||
(error.field === 'phone' && error.code === 'not_blank') ||
(error.field === 'phoneValid' && error.code === 'assert_true')
);
}
function serverErrorSignature(errors: FieldValidationError[]): string {
return errors
.map((error) => `${error.field}:${error.code}`)
.sort()
.join('|');
}
@@ -69,6 +69,8 @@
[profile]="profileModel()"
[saving]="saving()"
[saveError]="saveError()"
[saveFieldErrors]="saveFieldErrors()"
(fieldChanged)="clearSaveFieldError($event)"
(saveRequested)="save($event)"
/>
}
@@ -97,4 +97,43 @@ describe('SettingsPage', () => {
'alex@example.com',
);
});
it('keeps structured profile errors beside their field until that field changes', async () => {
profile.updateProfile.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
error: {
code: 'validation_failed',
errors: [{ field: 'phoneValid', code: 'assert_true' }],
},
status: 400,
}),
),
);
const fixture = TestBed.createComponent(SettingsPage);
fixture.detectChanges();
await fixture.whenStable();
fixture.nativeElement.querySelector('button.primary-action').click();
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain(
'Saisissez un numéro de téléphone valide.',
);
expect(fixture.nativeElement.textContent).not.toContain(
'Veuillez corriger les champs signalés.',
);
const phone = fixture.nativeElement.querySelector(
'input#profile-phone',
) as HTMLInputElement;
phone.value = '+33612345678';
phone.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
'Saisissez un numéro de téléphone valide.',
);
});
});
@@ -6,7 +6,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio';
import { LandlordProfileForm } from '../landlord-profile-form/landlord-profile-form';
import { httpErrorMessage } from '../../services/http-error-message';
import { FieldValidationError, saveFailure } 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';
@@ -45,6 +45,7 @@ export class SettingsPage {
readonly profileError = signal('');
readonly saveError = signal('');
readonly saveFieldErrors = signal<FieldValidationError[]>([]);
readonly saveSuccess = signal('');
constructor() {
@@ -79,6 +80,7 @@ export class SettingsPage {
if (this.saving()) return;
this.saveSuccess.set('');
this.saveError.set('');
this.saveFieldErrors.set([]);
this.saving.set(true);
this.landlordProfile
.updateProfile(profile)
@@ -90,14 +92,21 @@ export class SettingsPage {
this.saving.set(false);
},
error: (error: unknown) => {
this.saveError.set(
httpErrorMessage(
error,
'Impossible d’enregistrer votre profil bailleur. Réessayez.',
),
const failure = saveFailure(
error,
'Impossible d’enregistrer votre profil bailleur. Réessayez.',
);
this.saveError.set(failure.message);
this.saveFieldErrors.set(failure.fieldErrors);
this.saving.set(false);
},
});
}
clearSaveFieldError(field: string): void {
const remaining = this.saveFieldErrors().filter((error) => error.field !== field);
if (remaining.length === this.saveFieldErrors().length) return;
this.saveFieldErrors.set(remaining);
if (!remaining.length) this.saveError.set('');
}
}