Align apartment core validation errors (#43)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 878da155bd
commit b9f5540394
13 files changed
+454 -56

No files matched your search

@@ -22,6 +22,14 @@
font-size: 0.8rem;
line-height: 1.6;
}
.core-field mat-form-field {
width: 100%;
}
.field-error {
margin: -14px 0 0;
color: var(--danger);
font-size: 0.75rem;
}
@media (min-width: 1180px) {
.form-page {
max-width: 980px;
@@ -37,33 +37,55 @@
<p>Les informations utilisées dans votre portefeuille.</p>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Nom de l'appartement</mat-label
><input
matInput
type="text"
id="name"
[formField]="apartmentForm.name"
placeholder="T2 Paris 15"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Prix d'acquisition</mat-label
><input
matInput
type="number"
id="purchasePrice"
[formField]="apartmentForm.purchasePrice"
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
type="text"
id="address"
[formField]="apartmentForm.address"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
/></mat-form-field>
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Nom de l'appartement</mat-label
><input
matInput
type="text"
id="name"
[formField]="apartmentForm.name"
(input)="fieldChanged.emit('name')"
placeholder="T2 Paris 15"
/>
</mat-form-field>
@if (nameError()) {
<p class="field-error" role="alert">{{ nameError() }}</p>
}
</div>
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Prix d'acquisition</mat-label
><input
matInput
type="number"
id="purchasePrice"
[formField]="apartmentForm.purchasePrice"
(input)="fieldChanged.emit('purchasePrice')"
step="any"
/>
<span matTextSuffix>€</span></mat-form-field
>
@if (purchasePriceError()) {
<p class="field-error" role="alert">{{ purchasePriceError() }}</p>
}
</div>
<div class="core-field full-field">
<mat-form-field appearance="outline"
><mat-label>Adresse complète</mat-label
><input
matInput
type="text"
id="address"
[formField]="apartmentForm.address"
(input)="fieldChanged.emit('address')"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
/>
</mat-form-field>
@if (addressError()) {
<p class="field-error" role="alert">{{ addressError() }}</p>
}
</div>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Description ou notes</mat-label
><textarea
@@ -50,7 +50,7 @@ describe('ApartmentForm', () => {
form.save();
expect(form.error()).toContain('Veuillez remplir tous les champs obligatoires');
expect(form.error()).toContain('Le nom de l’appartement est obligatoire.');
expect(saved).not.toHaveBeenCalled();
});
@@ -70,10 +70,70 @@ describe('ApartmentForm', () => {
form.save();
expect(form.error()).toContain('Veuillez remplir tous les champs obligatoires');
expect(form.nameError()).toBe('Le nom de l’appartement est obligatoire.');
expect(form.addressError()).toBe('L’adresse complète est obligatoire.');
expect(saved).not.toHaveBeenCalled();
});
it('accepts every strictly positive purchase price, including a sub-euro value', () => {
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: 0.5,
description: '',
privateAreas: [],
legalProfile: emptyApartmentLegalProfile(),
});
form.save();
expect(saved).toHaveBeenCalledWith(expect.objectContaining({ purchasePrice: 0.5 }));
});
it('shows backend core errors beside their controls and clears one when it is edited', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
const form = fixture.componentInstance;
const changed = vi.fn();
form.fieldChanged.subscribe(changed);
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'name', code: 'not_blank' },
{ field: 'address', code: 'not_blank' },
]);
fixture.detectChanges();
await fixture.whenStable();
expect(form.nameError()).toBe('Le nom de l’appartement est obligatoire.');
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('.field-error')[0].textContent).toContain(
'Le nom de l’appartement est obligatoire.',
);
expect(fixture.nativeElement.querySelectorAll('.field-error')[1].textContent).toContain(
'L’adresse complète est obligatoire.',
);
fixture.nativeElement.querySelector('input#name').dispatchEvent(new Event('input'));
expect(changed).toHaveBeenCalledWith('name');
});
it('keeps unknown server validation codes in the generic save alert', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
fixture.componentRef.setInput('saveFieldErrors', [{ field: 'name', code: 'unknown' }]);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.field-error')).toBeNull();
expect(fixture.nativeElement.querySelector('.alert-error').textContent).toContain(
'Veuillez corriger les champs signalés.',
);
});
it('emits a trimmed apartment draft when valid', () => {
const fixture = TestBed.createComponent(ApartmentForm);
const form = fixture.componentInstance;
@@ -13,9 +13,17 @@ import {
} from '../../services/apartment.service';
import { ApartmentLegalProfileForm } from '../apartment-legal-profile-form/apartment-legal-profile-form';
import { PrivateAreaEditor } from '../private-area-editor/private-area-editor';
import { FieldValidationError } from '../../services/http-error-message';
const requiredFieldsError =
'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).';
const nameRequiredError = 'Le nom de l’appartement est obligatoire.';
const addressRequiredError = 'L’adresse complète est obligatoire.';
const purchasePriceError = 'Le prix d’acquisition doit être strictement positif.';
type CoreField = 'name' | 'address' | 'purchasePrice';
const serverErrorCodes: Record<CoreField, ReadonlySet<string>> = {
name: new Set(['not_blank']),
address: new Set(['not_blank']),
purchasePrice: new Set(['positive']),
};
@Component({
selector: 'app-apartment-form',
@@ -37,8 +45,10 @@ export class ApartmentForm {
readonly apartment = input<Apartment | null>(null);
readonly saving = input(false);
readonly saveError = input('');
readonly saveFieldErrors = input<FieldValidationError[]>([]);
readonly closed = output<void>();
readonly saved = output<Apartment>();
readonly fieldChanged = output<string>();
readonly apartmentModel = signal<Apartment>({
name: '',
@@ -50,10 +60,10 @@ export class ApartmentForm {
});
readonly apartmentForm = form(this.apartmentModel, (f) => {
required(f.name, { message: requiredFieldsError });
required(f.address, { message: requiredFieldsError });
required(f.purchasePrice, { message: requiredFieldsError });
min(f.purchasePrice, 1, { message: requiredFieldsError });
required(f.name, { message: nameRequiredError });
required(f.address, { message: addressRequiredError });
required(f.purchasePrice, { message: purchasePriceError });
min(f.purchasePrice, Number.MIN_VALUE, { message: purchasePriceError });
min(f.legalProfile.habitableSurface, 0, {
message: 'La surface habitable doit être positive ou nulle.',
});
@@ -87,7 +97,7 @@ export class ApartmentForm {
!this.apartmentModel().address.trim()
) {
const firstError = this.apartmentForm().errorSummary()[0];
this.error.set(firstError?.message ?? requiredFieldsError);
this.error.set(firstError?.message ?? this.coreFieldError() ?? nameRequiredError);
return;
}
@@ -106,6 +116,48 @@ export class ApartmentForm {
this.apartmentModel.update((apartment) => ({ ...apartment, privateAreas }));
}
nameError(): string {
return this.fieldError('name', nameRequiredError, this.apartmentForm.name().touched());
}
addressError(): string {
return this.fieldError(
'address',
addressRequiredError,
this.apartmentForm.address().touched(),
);
}
purchasePriceError(): string {
return this.fieldError(
'purchasePrice',
purchasePriceError,
this.apartmentForm.purchasePrice().touched(),
);
}
private coreFieldError(): string | null {
return this.nameError() || this.addressError() || this.purchasePriceError() || null;
}
private fieldError(path: CoreField, clientMessage: string, touched: boolean): string {
if (
touched &&
((path === 'name' && !this.apartmentModel().name.trim()) ||
(path === 'address' && !this.apartmentModel().address.trim()) ||
(path === 'purchasePrice' && this.apartmentModel().purchasePrice <= 0))
) {
return clientMessage;
}
return this.hasKnownServerError(path) ? clientMessage : '';
}
private hasKnownServerError(field: CoreField): boolean {
return this.saveFieldErrors().some(
(error) => error.field === field && serverErrorCodes[field].has(error.code),
);
}
private resetForm(apartment: Apartment | null): void {
this.apartmentModel.set({
id: apartment?.id,
@@ -15,6 +15,8 @@
[apartment]="editingApartment()"
[saving]="workspace.apartmentSaving(editingApartment()?.id)"
[saveError]="workspace.apartmentSaveError(editingApartment()?.id)"
[saveFieldErrors]="workspace.apartmentSaveFieldErrors(editingApartment()?.id)"
(fieldChanged)="workspace.clearApartmentSaveFieldError($event, editingApartment()?.id)"
(closed)="closeForm()"
(saved)="saveApartment($event)"
/>
@@ -14,7 +14,9 @@ describe('ApartmentsPage', () => {
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
apartmentSaving: vi.fn(() => false),
apartmentSaveError: vi.fn(() => ''),
apartmentSaveFieldErrors: vi.fn(() => []),
clearApartmentSaveError: vi.fn(),
clearApartmentSaveFieldError: vi.fn(),
deletingApartmentIds: signal<ReadonlySet<string>>(new Set()),
apartmentDeleteErrors: signal<Record<string, string>>({}),
loadApartments: () => undefined,