Align optional apartment legal profile validation (#45)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 72d3f0ccfa
commit c1e7964f2e
10 files changed
+477 -56

No files matched your search

@@ -99,7 +99,11 @@
</div>
</section>
<app-apartment-legal-profile-form [legalProfileForm]="apartmentForm.legalProfile" />
<app-apartment-legal-profile-form
[legalProfileForm]="apartmentForm.legalProfile"
[serverErrors]="saveFieldErrors()"
(fieldChanged)="fieldChanged.emit($event)"
/>
<app-private-area-editor
[areas]="apartmentModel().privateAreas"
[serverErrors]="saveFieldErrors()"
@@ -1,5 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { vi } from 'vitest';
import { ApartmentLegalProfileForm } from '../apartment-legal-profile-form/apartment-legal-profile-form';
import { ApartmentForm } from './apartment-form';
import { emptyApartmentLegalProfile } from '../../services/apartment.service';
@@ -293,6 +295,101 @@ describe('ApartmentForm', () => {
expect(saved).not.toHaveBeenCalled();
});
it('accepts zero legal-profile numbers with the first valid reference year', () => {
const fixture = TestBed.createComponent(ApartmentForm);
const form = fixture.componentInstance;
const saved = vi.fn();
form.saved.subscribe(saved);
form.apartmentModel.set({
name: 'T2',
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: {
...emptyApartmentLegalProfile(),
habitableSurface: 0,
estimatedAnnualEnergyCost: 0,
estimatedAnnualEnergyCostReferenceYear: 1900,
},
});
form.save();
expect(saved).toHaveBeenCalledWith(
expect.objectContaining({
legalProfile: expect.objectContaining({
habitableSurface: 0,
estimatedAnnualEnergyCost: 0,
estimatedAnnualEnergyCostReferenceYear: 1900,
}),
}),
);
});
it('rejects an out-of-range energy reference year when an estimated cost is provided', () => {
const fixture = TestBed.createComponent(ApartmentForm);
const form = fixture.componentInstance;
const saved = vi.fn();
form.saved.subscribe(saved);
form.apartmentModel.set({
name: 'T2',
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: {
...emptyApartmentLegalProfile(),
estimatedAnnualEnergyCost: 850,
estimatedAnnualEnergyCostReferenceYear: 1800,
},
});
form.save();
expect(form.error()).toContain('1900 et 2100');
expect(saved).not.toHaveBeenCalled();
});
it('rejects an invalid department code inline', () => {
const fixture = TestBed.createComponent(ApartmentForm);
const form = fixture.componentInstance;
const saved = vi.fn();
form.saved.subscribe(saved);
form.apartmentModel.set({
name: 'T2',
address: '1 rue de Paris',
purchasePrice: 250000,
description: '',
privateAreas: [],
legalProfile: { ...emptyApartmentLegalProfile(), departmentCode: '20' },
});
form.save();
expect(form.error()).toContain('département français');
expect(saved).not.toHaveBeenCalled();
});
it('shows known legal-profile server errors next to their controls', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'legalProfile.departmentCodeValid', code: 'assert_true' },
]);
const changed = vi.fn();
fixture.componentInstance.fieldChanged.subscribe(changed);
fixture.detectChanges();
await fixture.whenStable();
expect(
fixture.debugElement
.query(By.directive(ApartmentLegalProfileForm))
.componentInstance.departmentCodeError(),
).toContain('Saisissez un département français');
fixture.nativeElement.querySelector('#departmentCode').dispatchEvent(new Event('input'));
expect(changed).toHaveBeenCalledWith('legalProfile.departmentCodeValid');
});
it('turns an emptied nullable number field back into null', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.detectChanges();
@@ -337,7 +434,7 @@ describe('ApartmentForm', () => {
);
});
it('clears an energy reference year that has no estimated cost', async () => {
it('clears a stale energy reference year that has no estimated cost', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('apartment', {
id: 'apartment-1',
@@ -348,7 +445,7 @@ describe('ApartmentForm', () => {
privateAreas: [],
legalProfile: {
...emptyApartmentLegalProfile(),
estimatedAnnualEnergyCostReferenceYear: 2021,
estimatedAnnualEnergyCostReferenceYear: 1800,
},
});
fixture.detectChanges();
@@ -1,6 +1,6 @@
import { Component, effect, input, output, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormRoot, FormField, form, max, min, required } from '@angular/forms/signals';
import { FormRoot, FormField, form, max, min, 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';
@@ -76,11 +76,22 @@ export class ApartmentForm {
message: "Renseignez l'année de référence du coût énergétique.",
});
min(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 1900, {
when: ({ valueOf }) => valueOf(f.legalProfile.estimatedAnnualEnergyCost) !== null,
message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.",
});
max(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 2100, {
when: ({ valueOf }) => valueOf(f.legalProfile.estimatedAnnualEnergyCost) !== null,
message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.",
});
validate(f.legalProfile.departmentCode, ({ value }) =>
isFrenchDepartmentCode(value())
? null
: {
kind: 'department-code',
message:
'Saisissez un département français : 01 à 95 (sauf 20), 2A, 2B ou 971 à 976.',
},
);
});
readonly error = signal('');
@@ -201,3 +212,23 @@ export class ApartmentForm {
};
}
}
function isFrenchDepartmentCode(value: string): boolean {
const code = value.trim().toUpperCase();
if (!code) return true;
if (code === '2A' || code === '2B') return true;
if (!digits(code)) return false;
const number = Number(code);
return (
(code.length === 2 && number >= 1 && number <= 95 && number !== 20) ||
(code.length === 3 && number >= 971 && number <= 976)
);
}
function digits(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const character = value.charCodeAt(index);
if (character < 48 || character > 57) return false;
}
return true;
}
@@ -22,3 +22,14 @@
font-size: 0.8rem;
line-height: 1.6;
}
.field-error {
margin: -14px 0 0;
color: var(--danger);
font-size: 0.75rem;
}
.legal-profile-field {
min-width: 0;
}
.legal-profile-field mat-form-field {
width: 100%;
}
@@ -12,15 +12,23 @@
id="taxIdentifier"
[formField]="legalProfileForm().taxIdentifier"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Département</mat-label
><input
matInput
type="text"
id="departmentCode"
[formField]="legalProfileForm().departmentCode"
placeholder="75, 2A, 971…"
/></mat-form-field>
<div class="legal-profile-field">
<mat-form-field appearance="outline"
><mat-label>Département</mat-label
><input
matInput
type="text"
id="departmentCode"
[formField]="legalProfileForm().departmentCode"
(input)="clearServerErrors('legalProfile.departmentCodeValid')"
placeholder="75, 2A, 971…"
/>
<mat-hint>01 à 95 (sauf 20), 2A, 2B ou 971 à 976</mat-hint>
</mat-form-field>
@if (departmentCodeError()) {
<p class="field-error" role="alert">{{ departmentCodeError() }}</p>
}
</div>
<mat-form-field appearance="outline"
><mat-label>Type de bâtiment</mat-label
><mat-select id="buildingType" [formField]="legalProfileForm().buildingType">
@@ -34,6 +42,7 @@
><mat-select
id="constructionPeriod"
[formField]="legalProfileForm().constructionPeriod"
(selectionChange)="clearServerErrors('legalProfile.constructionPeriodValid')"
>
<mat-option [value]="null">Non renseignée</mat-option>
<mat-option value="BEFORE_1949">Avant 1949</mat-option>
@@ -62,42 +71,78 @@
<p>Surface, usage et performance énergétique.</p>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Surface habitable</mat-label
><input
matInput
type="number"
id="habitableSurface"
[formField]="legalProfileForm().habitableSurface"
/><span matTextSuffix>m²</span></mat-form-field
>
<div class="legal-profile-field">
<mat-form-field appearance="outline"
><mat-label>Surface habitable</mat-label
><input
matInput
type="number"
id="habitableSurface"
[formField]="legalProfileForm().habitableSurface"
(input)="clearServerErrors('legalProfile.habitableSurface')"
/>
<span matTextSuffix>m²</span>
</mat-form-field>
@if (habitableSurfaceError()) {
<p class="field-error" role="alert">{{ habitableSurfaceError() }}</p>
}
</div>
<mat-form-field appearance="outline"
><mat-label>Classe énergétique</mat-label
><mat-select id="energyRating" [formField]="legalProfileForm().energyRating">
><mat-select
id="energyRating"
[formField]="legalProfileForm().energyRating"
(selectionChange)="clearServerErrors('legalProfile.energyRatingValid')"
>
<mat-option [value]="null">Non renseignée</mat-option>
@for (rating of energyRatings; track rating) {
<mat-option [value]="rating">{{ rating }}</mat-option>
}
</mat-select></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Coût annuel énergétique estimé</mat-label
><input
matInput
type="number"
id="estimatedAnnualEnergyCost"
[formField]="legalProfileForm().estimatedAnnualEnergyCost"
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Année de référence du coût énergétique</mat-label
><input
matInput
type="number"
id="estimatedAnnualEnergyCostReferenceYear"
[formField]="legalProfileForm().estimatedAnnualEnergyCostReferenceYear"
placeholder="2021"
/></mat-form-field>
<div class="legal-profile-field">
<mat-form-field appearance="outline"
><mat-label>Coût annuel énergétique estimé</mat-label
><input
matInput
type="number"
id="estimatedAnnualEnergyCost"
[formField]="legalProfileForm().estimatedAnnualEnergyCost"
(input)="
clearServerErrors(
'legalProfile.estimatedAnnualEnergyCost',
'legalProfile.referenceYearPresentWhenEnergyCostIsProvided',
'legalProfile.referenceYearWithinBoundsWhenEnergyCostIsProvided'
)
"
/>
<span matTextSuffix>€</span>
</mat-form-field>
@if (estimatedAnnualEnergyCostError()) {
<p class="field-error" role="alert">{{ estimatedAnnualEnergyCostError() }}</p>
}
</div>
<div class="legal-profile-field">
<mat-form-field appearance="outline"
><mat-label>Année de référence du coût énergétique</mat-label
><input
matInput
type="number"
id="estimatedAnnualEnergyCostReferenceYear"
[formField]="legalProfileForm().estimatedAnnualEnergyCostReferenceYear"
(input)="
clearServerErrors(
'legalProfile.referenceYearPresentWhenEnergyCostIsProvided',
'legalProfile.referenceYearWithinBoundsWhenEnergyCostIsProvided'
)
"
placeholder="2021"
/>
</mat-form-field>
@if (referenceYearError()) {
<p class="field-error" role="alert">{{ referenceYearError() }}</p>
}
</div>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Destination des locaux</mat-label
><input
@@ -1,10 +1,14 @@
import { Component, input } from '@angular/core';
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FieldTree, FormField } from '@angular/forms/signals';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { ApartmentLegalProfile } from '../../services/apartment.service';
import { FieldValidationError } from '../../services/http-error-message';
const departmentCodeError =
'Saisissez un département français : 01 à 95 (sauf 20), 2A, 2B ou 971 à 976.';
@Component({
selector: 'app-apartment-legal-profile-form',
@@ -14,5 +18,70 @@ import { ApartmentLegalProfile } from '../../services/apartment.service';
})
export class ApartmentLegalProfileForm {
readonly legalProfileForm = input.required<FieldTree<ApartmentLegalProfile>>();
readonly serverErrors = input<FieldValidationError[]>([]);
readonly fieldChanged = output<string>();
readonly energyRatings = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] as const;
clearServerErrors(...fields: string[]): void {
for (const field of fields) this.fieldChanged.emit(field);
}
habitableSurfaceError(): string {
return this.legalProfileForm().habitableSurface().touched() &&
this.legalProfileForm().habitableSurface().invalid()
? 'La surface habitable doit être positive ou nulle.'
: this.serverError(
'legalProfile.habitableSurface',
['positive_or_zero'],
'La surface habitable doit être positive ou nulle.',
);
}
estimatedAnnualEnergyCostError(): string {
return this.legalProfileForm().estimatedAnnualEnergyCost().touched() &&
this.legalProfileForm().estimatedAnnualEnergyCost().invalid()
? 'Le coût annuel énergétique doit être positif ou nul.'
: this.serverError(
'legalProfile.estimatedAnnualEnergyCost',
['positive_or_zero'],
'Le coût annuel énergétique doit être positif ou nul.',
);
}
referenceYearError(): string {
return this.legalProfileForm().estimatedAnnualEnergyCostReferenceYear().touched() &&
this.legalProfileForm().estimatedAnnualEnergyCostReferenceYear().invalid()
? this.legalProfileForm().estimatedAnnualEnergyCostReferenceYear().getError('required')
? "Renseignez l'année de référence du coût énergétique."
: "L'année de référence énergétique doit être comprise entre 1900 et 2100."
: this.serverError(
'legalProfile.referenceYearPresentWhenEnergyCostIsProvided',
['assert_true'],
"Renseignez l'année de référence du coût énergétique.",
) ||
this.serverError(
'legalProfile.referenceYearWithinBoundsWhenEnergyCostIsProvided',
['assert_true'],
"L'année de référence énergétique doit être comprise entre 1900 et 2100.",
);
}
departmentCodeError(): string {
return this.legalProfileForm().departmentCode().touched() &&
this.legalProfileForm().departmentCode().invalid()
? departmentCodeError
: this.serverError(
'legalProfile.departmentCodeValid',
['assert_true'],
departmentCodeError,
);
}
private serverError(field: string, codes: readonly string[], message: string): string {
return this.serverErrors().some(
(error) => error.field === field && codes.includes(error.code),
)
? message
: '';
}
}