Complete apartment form validation flow (#46)
This commit is contained in:
1 parent
c1e7964f2e
commit
d1cdc6eaeb
11 files changed
+351
-21
No files matched your search
@@ -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;
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+16
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user