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,
@@ -0,0 +1,46 @@
import { saveFailure } from './http-error-message';
describe('saveFailure', () => {
const fallback = 'Impossible d’enregistrer cet appartement.';
it('keeps only the structured validation contract', () => {
expect(
saveFailure(
{
status: 400,
error: {
code: 'validation_failed',
errors: [{ field: 'name', code: 'not_blank' }],
},
},
fallback,
),
).toEqual({
message: 'Veuillez corriger les champs signalés.',
fieldErrors: [{ field: 'name', code: 'not_blank' }],
});
});
it('uses a safe fallback for unknown, network, and unexpected failures', () => {
expect(
saveFailure({ status: 500, error: { message: 'internal detail' } }, fallback),
).toEqual({
message: fallback,
fieldErrors: [],
});
expect(saveFailure({ status: 0 }, fallback)).toEqual({
message: fallback,
fieldErrors: [],
});
expect(saveFailure({}, fallback)).toEqual({ message: fallback, fieldErrors: [] });
});
it('keeps a safe conflict message', () => {
expect(
saveFailure(
{ status: 409, error: { message: 'Cet appartement est référencé.' } },
fallback,
),
).toEqual({ message: 'Cet appartement est référencé.', fieldErrors: [] });
});
});
@@ -1,3 +1,13 @@
export interface FieldValidationError {
field: string;
code: string;
}
export interface SaveFailure {
message: string;
fieldErrors: FieldValidationError[];
}
export function httpErrorMessage(error: unknown, fallback: string): string {
return typeof error === 'object' &&
error !== null &&
@@ -9,3 +19,49 @@ export function httpErrorMessage(error: unknown, fallback: string): string {
? error.error.message
: fallback;
}
export function saveFailure(error: unknown, fallback: string): SaveFailure {
const validationErrors = validationErrorsFrom(error);
if (validationErrors) {
return {
message: 'Veuillez corriger les champs signalés.',
fieldErrors: validationErrors,
};
}
return {
message: isConflict(error) ? httpErrorMessage(error, fallback) : fallback,
fieldErrors: [],
};
}
function validationErrorsFrom(error: unknown): FieldValidationError[] | null {
if (
typeof error !== 'object' ||
error === null ||
!('error' in error) ||
typeof error.error !== 'object' ||
error.error === null ||
!('code' in error.error) ||
error.error.code !== 'validation_failed' ||
!('errors' in error.error) ||
!Array.isArray(error.error.errors)
) {
return null;
}
const fieldErrors = error.error.errors.filter(
(fieldError): fieldError is FieldValidationError =>
typeof fieldError === 'object' &&
fieldError !== null &&
'field' in fieldError &&
typeof fieldError.field === 'string' &&
'code' in fieldError &&
typeof fieldError.code === 'string',
);
return fieldErrors.length === error.error.errors.length ? fieldErrors : null;
}
function isConflict(error: unknown): error is { status: number } {
return typeof error === 'object' && error !== null && 'status' in error && error.status === 409;
}
@@ -1,6 +1,6 @@
import { signal } from '@angular/core';
import { finalize, Observable } from 'rxjs';
import { httpErrorMessage } from './http-error-message';
import { FieldValidationError, httpErrorMessage, saveFailure } from './http-error-message';
import { withoutRecordKey, withoutSetValue } from './immutable-state';
export interface LoadState {
@@ -20,6 +20,7 @@ export class WorkspaceResource<T extends { id?: string }> {
readonly loadState = signal<LoadState>({ loaded: false, loading: false, error: '' });
readonly savingKeys = signal<ReadonlySet<string>>(new Set());
readonly saveErrors = signal<Record<string, string>>({});
readonly saveFieldErrors = signal<Record<string, FieldValidationError[]>>({});
readonly deletingIds = signal<ReadonlySet<string>>(new Set());
readonly deleteErrors = signal<Record<string, string>>({});
private revision = 0;
@@ -47,6 +48,7 @@ export class WorkspaceResource<T extends { id?: string }> {
if (this.savingKeys().has(key)) return;
this.savingKeys.update((keys) => new Set(keys).add(key));
this.saveErrors.update((errors) => withoutRecordKey(errors, key));
this.saveFieldErrors.update((errors) => withoutRecordKey(errors, key));
request
.pipe(finalize(() => this.savingKeys.update((keys) => withoutSetValue(keys, key))))
.subscribe({
@@ -55,8 +57,14 @@ export class WorkspaceResource<T extends { id?: string }> {
onSuccess?.();
this.load();
},
error: () =>
this.saveErrors.update((errors) => ({ ...errors, [key]: this.messages.save })),
error: (error) => {
const failure = saveFailure(error, this.messages.save);
this.saveErrors.update((errors) => ({ ...errors, [key]: failure.message }));
this.saveFieldErrors.update((errors) => ({
...errors,
[key]: failure.fieldErrors,
}));
},
});
}
@@ -95,6 +103,17 @@ export class WorkspaceResource<T extends { id?: string }> {
clearSaveError(key: string): void {
this.saveErrors.update((errors) => withoutRecordKey(errors, key));
this.saveFieldErrors.update((errors) => withoutRecordKey(errors, key));
}
clearSaveFieldError(key: string, field: string): void {
const fieldErrors = this.saveFieldErrors()[key];
if (!fieldErrors?.some((error) => error.field === field)) return;
const remaining = fieldErrors.filter((error) => error.field !== field);
this.saveFieldErrors.update((errors) =>
remaining.length ? { ...errors, [key]: remaining } : withoutRecordKey(errors, key),
);
if (!remaining.length) this.saveErrors.update((errors) => withoutRecordKey(errors, key));
}
private handleLoadError(revision: number): void {
@@ -298,6 +298,36 @@ describe('WorkspaceService', () => {
expect(service.apartmentSaving(secondApartment.id)).toBe(true);
});
it('preserves validation fields and clears only the corrected apartment field', () => {
apartments.updateApartment.mockReturnValueOnce(
throwError(() => ({
status: 400,
error: {
code: 'validation_failed',
errors: [
{ field: 'name', code: 'not_blank' },
{ field: 'address', code: 'not_blank' },
],
},
})),
);
const service = TestBed.inject(WorkspaceService);
service.saveApartment(apartment, apartment.id);
expect(service.apartmentSaveFieldErrors(apartment.id)).toEqual([
{ field: 'name', code: 'not_blank' },
{ field: 'address', code: 'not_blank' },
]);
service.clearApartmentSaveFieldError('name', apartment.id);
expect(service.apartmentSaveFieldErrors(apartment.id)).toEqual([
{ field: 'address', code: 'not_blank' },
]);
expect(service.apartmentSaveError(apartment.id)).toBe(
'Veuillez corriger les champs signalés.',
);
});
it('removes a confirmed deletion locally before reconciliation finishes', () => {
const deleteResponse = new Subject<void>();
const refreshResponse = new Subject<(typeof apartment)[]>();
@@ -12,6 +12,7 @@ import { RentPayment, RentPaymentService } from './rent-payment.service';
import { Tenant, TenantAttachment, TenantService } from './tenant.service';
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
import { WorkspaceResource } from './workspace-resource';
import { FieldValidationError } from './http-error-message';
const newResourceKey = 'new';
@@ -108,10 +109,18 @@ export class WorkspaceService {
return this.apartmentResource.saveErrors()[this.resourceKey(id)] ?? '';
}
apartmentSaveFieldErrors(id?: string): FieldValidationError[] {
return this.apartmentResource.saveFieldErrors()[this.resourceKey(id)] ?? [];
}
clearApartmentSaveError(id?: string): void {
this.apartmentResource.clearSaveError(this.resourceKey(id));
}
clearApartmentSaveFieldError(field: string, id?: string): void {
this.apartmentResource.clearSaveFieldError(this.resourceKey(id), field);
}
deleteApartment(id: string): void {
this.requestConfirmation(
{