Complete apartment form validation flow (#46)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent c1e7964f2e
commit d1cdc6eaeb
11 files changed
+351 -21

No files matched your search

+59
View File
@@ -259,6 +259,65 @@ test('keeps apartment add and edit workflows usable', async ({ page }) => {
await expectNoHorizontalOverflow(page);
});
test('keeps private-area validation focused and clears the structured server error on correction', async ({
page,
}) => {
const apartments: object[] = [];
let createAttempts = 0;
await mockUser(page, true);
await page.route('**/api/apartments', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ contentType: 'application/json', json: apartments });
return;
}
createAttempts += 1;
if (createAttempts === 1) {
await route.fulfill({
status: 400,
contentType: 'application/json',
json: {
code: 'validation_failed',
errors: [{ field: 'privateAreas[0].label', code: 'not_blank' }],
},
});
return;
}
const apartment = { id: 'apt-private-area', ...route.request().postDataJSON() };
apartments.push(apartment);
await route.fulfill({ contentType: 'application/json', json: apartment });
});
await page.goto('/');
await page
.getByTestId('primary-navigation')
.getByRole('button', { name: 'Appartements' })
.click();
await page.locator('.page-heading > .primary-action').click();
await page.getByLabel("Nom de l'appartement").fill('Studio Croix-Rousse');
await page.getByLabel('Adresse complète').fill('8 montée de la Boucle, Lyon');
await page.getByLabel("Prix d'acquisition").fill('125000');
await page.getByRole('button', { name: 'Ajouter un espace' }).click();
const label = page.getByLabel('Libellé');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Renseignez le libellé de chaque espace privatif.')).toBeVisible();
await expect(label).toBeFocused();
expect(createAttempts).toBe(0);
await label.fill('Chambre');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Le libellé est obligatoire.')).toBeVisible();
await label.fill('Chambre 1');
await expect(page.getByText('Le libellé est obligatoire.')).toBeHidden();
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'Studio Croix-Rousse' })).toBeVisible();
expect(createAttempts).toBe(2);
});
test('keeps the secured API check and logout available', async ({ page }) => {
let logoutMethod = '';
await mockUser(page, true);
@@ -30,6 +30,9 @@
color: var(--danger);
font-size: 0.75rem;
}
.required-marker {
color: var(--danger);
}
@media (min-width: 1180px) {
.form-page {
max-width: 980px;
@@ -21,7 +21,7 @@
class="apartment-form"
[attr.aria-busy]="saving()"
>
@if (saveError()) {
@if (showSaveError()) {
<div class="alert-error" role="alert">{{ saveError() }}</div>
}
@if (error()) {
@@ -39,13 +39,16 @@
<div class="form-grid">
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Nom de l'appartement</mat-label
><mat-label
>Nom de l'appartement
<span class="required-marker" aria-hidden="true">*</span></mat-label
><input
matInput
type="text"
id="name"
aria-required="true"
[formField]="apartmentForm.name"
(input)="fieldChanged.emit('name')"
(input)="fieldWasChanged('name')"
placeholder="T2 Paris 15"
/>
</mat-form-field>
@@ -55,13 +58,16 @@
</div>
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Prix d'acquisition</mat-label
><mat-label
>Prix d'acquisition
<span class="required-marker" aria-hidden="true">*</span></mat-label
><input
matInput
type="number"
id="purchasePrice"
aria-required="true"
[formField]="apartmentForm.purchasePrice"
(input)="fieldChanged.emit('purchasePrice')"
(input)="fieldWasChanged('purchasePrice')"
step="any"
/>
<span matTextSuffix>€</span></mat-form-field
@@ -72,13 +78,16 @@
</div>
<div class="core-field full-field">
<mat-form-field appearance="outline"
><mat-label>Adresse complète</mat-label
><mat-label
>Adresse complète
<span class="required-marker" aria-hidden="true">*</span></mat-label
><input
matInput
type="text"
id="address"
aria-required="true"
[formField]="apartmentForm.address"
(input)="fieldChanged.emit('address')"
(input)="fieldWasChanged('address')"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
/>
</mat-form-field>
@@ -102,13 +111,13 @@
<app-apartment-legal-profile-form
[legalProfileForm]="apartmentForm.legalProfile"
[serverErrors]="saveFieldErrors()"
(fieldChanged)="fieldChanged.emit($event)"
(fieldChanged)="fieldWasChanged($event)"
/>
<app-private-area-editor
[areas]="apartmentModel().privateAreas"
[serverErrors]="saveFieldErrors()"
(areasChanged)="setPrivateAreas($event)"
(fieldChanged)="fieldChanged.emit($event)"
(fieldChanged)="fieldWasChanged($event)"
/>
<div class="form-actions">
@@ -56,6 +56,71 @@ describe('ApartmentForm', () => {
expect(saved).not.toHaveBeenCalled();
});
it('shows every invalid section and focuses the first control in form order', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.detectChanges();
await fixture.whenStable();
const form = fixture.componentInstance;
const saved = vi.fn();
form.saved.subscribe(saved);
form.apartmentModel.set({
name: ' ',
address: ' ',
purchasePrice: 0,
description: '',
privateAreas: [
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'OTHER', label: ' ' },
],
legalProfile: {
...emptyApartmentLegalProfile(),
habitableSurface: -1,
departmentCode: '20',
},
});
form.save();
fixture.detectChanges();
await fixture.whenStable();
const text = fixture.nativeElement.textContent;
expect(saved).not.toHaveBeenCalled();
expect(text).toContain('Le nom de l’appartement est obligatoire.');
expect(text).toContain('Le prix d’acquisition doit être strictement positif.');
expect(text).toContain('L’adresse complète est obligatoire.');
expect(text).toContain('Saisissez un département français');
expect(text).toContain('La surface habitable doit être positive ou nulle.');
expect(text).toContain('Le libellé est obligatoire.');
expect(document.activeElement).toBe(fixture.nativeElement.querySelector('#name'));
});
it('clears its client summary when a field is corrected', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.detectChanges();
await fixture.whenStable();
const form = fixture.componentInstance;
form.save();
fixture.nativeElement.querySelector('#name').dispatchEvent(new Event('input'));
expect(form.error()).toBe('');
});
it('focuses the first mapped server error after a failed save', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'address', code: 'not_blank' },
{ field: 'legalProfile.departmentCodeValid', code: 'assert_true' },
{ field: 'privateAreas[0].label', code: 'not_blank' },
]);
fixture.detectChanges();
await fixture.whenStable();
expect(document.activeElement).toBe(fixture.nativeElement.querySelector('#address'));
});
it('rejects whitespace-only fields before the backend does', () => {
const fixture = TestBed.createComponent(ApartmentForm);
const form = fixture.componentInstance;
@@ -198,6 +263,34 @@ describe('ApartmentForm', () => {
);
});
it('keeps malformed private-area server paths in the generic alert', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'privateAreas[].label', code: 'not_blank' },
{ field: 'privateAreas[-1].label', code: 'not_blank' },
]);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.alert-error').textContent).toContain(
'Veuillez corriger les champs signalés.',
);
});
it('keeps only unknown server validation failures in the generic alert', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
fixture.componentRef.setInput('saveFieldErrors', [{ field: 'name', code: 'not_blank' }]);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.alert-error')).toBeNull();
expect(fixture.nativeElement.textContent).toContain(
'Le nom de l’appartement est obligatoire.',
);
});
it('emits a trimmed apartment draft when valid', () => {
const fixture = TestBed.createComponent(ApartmentForm);
const form = fixture.componentInstance;
@@ -1,4 +1,13 @@
import { Component, effect, input, output, signal, viewChild } from '@angular/core';
import {
Component,
effect,
ElementRef,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormRoot, FormField, form, max, min, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
@@ -42,6 +51,7 @@ const serverErrorCodes: Record<CoreField, ReadonlySet<string>> = {
styleUrl: './apartment-form.css',
})
export class ApartmentForm {
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);
readonly apartment = input<Apartment | null>(null);
readonly saving = input(false);
readonly saveError = input('');
@@ -50,6 +60,7 @@ export class ApartmentForm {
readonly saved = output<Apartment>();
readonly fieldChanged = output<string>();
readonly privateAreaEditor = viewChild(PrivateAreaEditor);
private previousServerErrorCount = 0;
readonly apartmentModel = signal<Apartment>({
name: '',
@@ -98,6 +109,13 @@ export class ApartmentForm {
constructor() {
effect(() => this.resetForm(this.apartment()));
effect(() => {
const errors = this.saveFieldErrors();
if (errors.length > this.previousServerErrorCount) {
queueMicrotask(() => this.focusFirstInvalid());
}
this.previousServerErrorCount = errors.length;
});
}
save(): void {
@@ -117,7 +135,7 @@ export class ApartmentForm {
this.privateAreaEditor()?.validationMessage() ??
nameRequiredError,
);
if (!privateAreasValid) this.privateAreaEditor()?.focusFirstInvalid();
queueMicrotask(() => this.focusFirstInvalid());
return;
}
@@ -136,6 +154,19 @@ export class ApartmentForm {
this.apartmentModel.update((apartment) => ({ ...apartment, privateAreas }));
}
fieldWasChanged(field: string): void {
this.error.set('');
this.fieldChanged.emit(field);
}
showSaveError(): boolean {
return (
Boolean(this.saveError()) &&
(this.saveFieldErrors().length === 0 ||
this.saveFieldErrors().some((error) => !isInlineServerError(error)))
);
}
nameError(): string {
return this.fieldError('name', nameRequiredError, this.apartmentForm.name().touched());
}
@@ -178,6 +209,67 @@ export class ApartmentForm {
);
}
private focusFirstInvalid(): void {
if (!this.element.nativeElement.querySelector('form')) return;
if (this.nameError()) return this.focus('#name');
if (this.purchasePriceError()) return this.focus('#purchasePrice');
if (this.addressError()) return this.focus('#address');
const legalProfileSelector = this.firstLegalProfileErrorSelector();
if (legalProfileSelector) return this.focus(legalProfileSelector);
this.privateAreaEditor()?.focusFirstInvalid();
}
private firstLegalProfileErrorSelector(): string {
const profile = this.apartmentModel().legalProfile;
if (
this.hasLegalServerError('legalProfile.departmentCodeValid') ||
!isFrenchDepartmentCode(profile.departmentCode)
) {
return '#departmentCode';
}
if (this.hasLegalServerError('legalProfile.constructionPeriodValid')) {
return '#constructionPeriod';
}
if (
this.hasLegalServerError('legalProfile.habitableSurface', 'positive_or_zero') ||
(profile.habitableSurface !== null && profile.habitableSurface < 0)
) {
return '#habitableSurface';
}
if (this.hasLegalServerError('legalProfile.energyRatingValid')) return '#energyRating';
if (
this.hasLegalServerError(
'legalProfile.estimatedAnnualEnergyCost',
'positive_or_zero',
) ||
(profile.estimatedAnnualEnergyCost !== null && profile.estimatedAnnualEnergyCost < 0)
) {
return '#estimatedAnnualEnergyCost';
}
if (
this.hasLegalServerError('legalProfile.referenceYearPresentWhenEnergyCostIsProvided') ||
this.hasLegalServerError(
'legalProfile.referenceYearWithinBoundsWhenEnergyCostIsProvided',
) ||
(profile.estimatedAnnualEnergyCost !== null &&
(profile.estimatedAnnualEnergyCostReferenceYear === null ||
profile.estimatedAnnualEnergyCostReferenceYear < 1900 ||
profile.estimatedAnnualEnergyCostReferenceYear > 2100))
) {
return '#estimatedAnnualEnergyCostReferenceYear';
}
return '';
}
private hasLegalServerError(field: string, code = 'assert_true'): boolean {
return this.saveFieldErrors().some((error) => error.field === field && error.code === code);
}
private focus(selector: string): void {
const control = this.element.nativeElement.querySelector(selector);
if (control instanceof HTMLElement) control.focus();
}
private resetForm(apartment: Apartment | null): void {
this.apartmentModel.set({
id: apartment?.id,
@@ -213,6 +305,46 @@ export class ApartmentForm {
}
}
function isInlineServerError(error: FieldValidationError): boolean {
if ((error.field === 'name' || error.field === 'address') && error.code === 'not_blank') {
return true;
}
if (error.field === 'purchasePrice' && error.code === 'positive') return true;
if (
(error.field === 'legalProfile.habitableSurface' ||
error.field === 'legalProfile.estimatedAnnualEnergyCost') &&
error.code === 'positive_or_zero'
) {
return true;
}
if (
(error.field === 'legalProfile.referenceYearPresentWhenEnergyCostIsProvided' ||
error.field === 'legalProfile.referenceYearWithinBoundsWhenEnergyCostIsProvided' ||
error.field === 'legalProfile.departmentCodeValid' ||
error.field === 'legalProfile.constructionPeriodValid' ||
error.field === 'legalProfile.energyRatingValid') &&
error.code === 'assert_true'
) {
return true;
}
const privateArea = privateAreaField(error.field);
return (
privateArea !== null &&
((privateArea === 'label' && error.code === 'not_blank') ||
((privateArea === 'family' || privateArea === 'subtype') &&
error.code === 'not_null') ||
(privateArea === 'subtypeCompatibleWithFamily' && error.code === 'assert_true'))
);
}
function privateAreaField(path: string): string | null {
if (!path.startsWith('privateAreas[')) return null;
const fieldOffset = path.indexOf('].');
if (fieldOffset === -1) return null;
const index = path.slice('privateAreas['.length, fieldOffset);
return index && digits(index) ? path.slice(fieldOffset + 2) : null;
}
function isFrenchDepartmentCode(value: string): boolean {
const code = value.trim().toUpperCase();
if (!code) return true;
@@ -1,7 +1,7 @@
<section class="form-section" aria-labelledby="legal-heading">
<div class="section-heading">
<h2 id="legal-heading">Identification juridique</h2>
<p>Ces champs facultatifs seront réutilisés dans les baux.</p>
<p>Ce profil juridique est facultatif ; ses données seront réutilisées dans les baux.</p>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
@@ -53,6 +53,9 @@
<mat-option value="AFTER_2012">Après 2012</mat-option>
</mat-select></mat-form-field
>
@if (constructionPeriodError()) {
<p class="field-error" role="alert">{{ constructionPeriodError() }}</p>
}
<mat-form-field appearance="outline" class="full-field"
><mat-label>Régime juridique de l'immeuble</mat-label
><input
@@ -100,6 +103,9 @@
}
</mat-select></mat-form-field
>
@if (energyRatingError()) {
<p class="field-error" role="alert">{{ energyRatingError() }}</p>
}
<div class="legal-profile-field">
<mat-form-field appearance="outline"
><mat-label>Coût annuel énergétique estimé</mat-label
@@ -77,6 +77,22 @@ export class ApartmentLegalProfileForm {
);
}
constructionPeriodError(): string {
return this.serverError(
'legalProfile.constructionPeriodValid',
['assert_true'],
'La période de construction est invalide.',
);
}
energyRatingError(): string {
return this.serverError(
'legalProfile.energyRatingValid',
['assert_true'],
'La classe énergétique est invalide.',
);
}
private serverError(field: string, codes: readonly string[], message: string): string {
return this.serverErrors().some(
(error) => error.field === field && codes.includes(error.code),
@@ -35,6 +35,7 @@
<mat-label>Famille</mat-label>
<mat-select
required
class="private-area-family"
[value]="area.family"
(selectionChange)="
updateArea(area.id, 'family', $event.value)
@@ -58,6 +59,7 @@
<mat-label>Sous-type</mat-label>
<mat-select
required
class="private-area-subtype"
[value]="area.subtype"
(selectionChange)="
updateArea(area.id, 'subtype', $event.value)
@@ -112,12 +112,8 @@ export class PrivateAreaEditor {
}
focusFirstInvalid(): void {
const index = this.areas().findIndex((area) => !area.label.trim());
if (index === -1) return;
queueMicrotask(() => {
const label = this.element.nativeElement.querySelectorAll('.private-area-label')[index];
if (label instanceof HTMLInputElement) label.focus();
});
const control = this.firstInvalidControl();
if (control instanceof HTMLElement) control.focus();
}
labelError(index: number, area: PrivateArea): string {
@@ -154,6 +150,20 @@ export class PrivateAreaEditor {
return this.serverErrors().some((error) => error.field === field && error.code === code);
}
private firstInvalidControl(): HTMLElement | null {
for (const [index, area] of this.areas().entries()) {
if (this.familyError(index)) return this.control('.private-area-family', index);
if (this.subtypeError(index)) return this.control('.private-area-subtype', index);
if (this.labelError(index, area)) return this.control('.private-area-label', index);
}
return null;
}
private control(selector: string, index: number): HTMLElement | null {
const control = this.element.nativeElement.querySelectorAll(selector)[index];
return control instanceof HTMLElement ? control : null;
}
private fieldPath(index: number, field: keyof Omit<PrivateArea, 'id'>): string {
return `privateAreas[${index}].${field}`;
}