Align apartment core validation errors (#43)
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user