Preserve actionable form errors

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent fe36d6cae4
commit 3a8ef0ae0b
17 files changed
+166 -63

No files matched your search

@@ -316,14 +316,7 @@ private Lease activeLease() {
private Tenant tenant(TenantId id, String firstName, String address) {
return new Tenant(
id,
LANDLORD_ID,
firstName,
"Martin",
firstName.toLowerCase() + "@example.com",
"",
address,
List.of());
id, LANDLORD_ID, firstName, "Martin", id.value() + "@example.com", "", address, List.of());
}
private RentDue due(YearMonth month) {
@@ -130,7 +130,7 @@ describe('LandlordProfileCompletionDialog', () => {
expect(fixture.nativeElement.textContent).toContain(
'Saisissez un numéro de téléphone valide.',
);
component.clearSaveFieldError('phone');
component.clearSaveFieldError('phoneValid');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain(
@@ -31,6 +31,13 @@
width: 100%;
}
.field-error {
display: block;
margin-top: -12px;
color: var(--danger);
font-size: 0.75rem;
}
.profile-actions {
display: flex;
flex-wrap: wrap;
@@ -21,7 +21,7 @@
autocomplete="given-name"
/>
@if (firstNameError()) {
<mat-error>{{ firstNameError() }}</mat-error>
<span class="field-error" role="alert">{{ firstNameError() }}</span>
}
</mat-form-field>
<mat-form-field appearance="outline"
@@ -34,7 +34,7 @@
autocomplete="family-name"
/>
@if (lastNameError()) {
<mat-error>{{ lastNameError() }}</mat-error>
<span class="field-error" role="alert">{{ lastNameError() }}</span>
}
</mat-form-field>
<mat-form-field appearance="outline"
@@ -48,7 +48,7 @@
autocomplete="email"
/>
@if (emailError()) {
<mat-error>{{ emailError() }}</mat-error>
<span class="field-error" role="alert">{{ emailError() }}</span>
}
</mat-form-field>
<mat-form-field appearance="outline"
@@ -62,7 +62,7 @@
autocomplete="tel"
/>
@if (phoneError()) {
<mat-error>{{ phoneError() }}</mat-error>
<span class="field-error" role="alert">{{ phoneError() }}</span>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-field"
@@ -75,7 +75,7 @@
autocomplete="street-address"
/>
@if (addressError()) {
<mat-error>{{ addressError() }}</mat-error>
<span class="field-error" role="alert">{{ addressError() }}</span>
}
</mat-form-field>
</fieldset>
@@ -22,7 +22,7 @@ describe('LandlordProfileForm', () => {
expect(saveRequested).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain(
'Veuillez renseigner tous les champs obligatoires.',
'Le numéro de téléphone est obligatoire.',
);
});
@@ -94,7 +94,6 @@ describe('LandlordProfileForm', () => {
]);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain(
'Saisissez un numéro de téléphone valide.',
);
@@ -108,6 +107,7 @@ describe('LandlordProfileForm', () => {
phone.dispatchEvent(new Event('input'));
expect(changed).toHaveBeenCalledWith('phone');
expect(changed).toHaveBeenCalledWith('phoneValid');
fixture.componentRef.setInput('saveFieldErrors', [{ field: 'email', code: 'email' }]);
fixture.detectChanges();
await fixture.whenStable();
@@ -17,6 +17,8 @@ const EMPTY_PROFILE: LandlordProfile = {
address: '',
};
type LandlordProfileField = keyof LandlordProfile | 'phoneValid';
@Component({
selector: 'app-landlord-profile-form',
imports: [
@@ -38,7 +40,7 @@ export class LandlordProfileForm {
readonly saveError = input('');
readonly saveFieldErrors = input<FieldValidationError[]>([]);
readonly saveRequested = output<LandlordProfile>();
readonly fieldChanged = output<keyof LandlordProfile>();
readonly fieldChanged = output<LandlordProfileField>();
private previousServerErrorSignature = '';
readonly profileModel = signal<LandlordProfile>({ ...this.profile() });
@@ -93,6 +95,7 @@ export class LandlordProfileForm {
fieldWasChanged(field: keyof LandlordProfile): void {
this.validationError.set('');
this.fieldChanged.emit(field);
if (field === 'phone') this.fieldChanged.emit('phoneValid');
}
showSaveError(): boolean {
@@ -189,6 +192,7 @@ export class LandlordProfileForm {
: this.addressError()
? '#profile-address'
: '';
if (!selector) return;
const control = this.element.nativeElement.querySelector(selector);
if (control instanceof HTMLElement) control.focus();
}
@@ -36,6 +36,9 @@ describe('LeaseAmendmentForm', () => {
indexFlatRate: false,
status: 'ACTIVE',
});
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.detectChanges();
fixture.componentInstance.amendmentModel.update((model) => ({
...model,
effectiveDate: '2026-10-01',
@@ -43,9 +46,6 @@ describe('LeaseAmendmentForm', () => {
monthlyCharges: 0,
dueDay: 28,
}));
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.detectChanges();
fixture.componentInstance.save();
@@ -84,14 +84,14 @@ describe('LeaseAmendmentForm', () => {
indexFlatRate: false,
status: 'ACTIVE',
});
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.detectChanges();
fixture.componentInstance.amendmentModel.update((model) => ({
...model,
effectiveDate: '2026-10-01',
endDate: '2026-09-30',
}));
const submitted = vi.fn();
fixture.componentInstance.submitted.subscribe(submitted);
fixture.detectChanges();
fixture.componentInstance.save();
@@ -89,7 +89,7 @@ describe('LeaseEndPage', () => {
});
});
it('shows a skeleton, then a retryable load error', () => {
it('shows a skeleton, then a retryable safe load error', () => {
const response = new Subject<LeaseEndState>();
getState.mockReturnValueOnce(response);
const fixture = TestBed.createComponent(LeaseEndPage);
@@ -99,7 +99,10 @@ describe('LeaseEndPage', () => {
response.error({ error: { message: 'Bail introuvable' } });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Bail introuvable');
expect(fixture.nativeElement.textContent).toContain(
'Impossible de charger la fin de ce bail.',
);
expect(fixture.nativeElement.textContent).not.toContain('Bail introuvable');
getState.mockReturnValueOnce(of(state));
fixture.nativeElement.querySelector('.load-error button').click();
@@ -204,7 +207,7 @@ describe('LeaseEndPage', () => {
expect(fixture.nativeElement.textContent).toContain('Enregistrer l’accord');
});
it('keeps notice loading and failures local without publishing false success', () => {
it('keeps notice loading and safe failures local without publishing false success', () => {
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
const response = new Subject<LeaseEndState>();
recordNotice.mockReturnValue(response);
@@ -228,7 +231,8 @@ describe('LeaseEndPage', () => {
response.error({ error: { message: 'Congé refusé' } });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Congé refusé');
expect(fixture.nativeElement.textContent).toContain('Impossible d’enregistrer ce congé.');
expect(fixture.nativeElement.textContent).not.toContain('Congé refusé');
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
});
@@ -278,7 +282,7 @@ describe('LeaseEndPage', () => {
expect(acceptWithdrawal).toHaveBeenCalledWith('lease-1', 'notice-1', request);
});
it('keeps a closure failure actionable', () => {
it('keeps a safe closure failure actionable', () => {
close.mockReturnValue(throwError(() => ({ error: { message: 'Clôture refusée' } })));
getState.mockReturnValueOnce(of({ ...state, phase: 'AWAITING_CLOSURE' }));
const fixture = TestBed.createComponent(LeaseEndPage);
@@ -290,7 +294,8 @@ describe('LeaseEndPage', () => {
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Clôture refusée');
expect(fixture.nativeElement.textContent).toContain('Impossible de clôturer ce bail.');
expect(fixture.nativeElement.textContent).not.toContain('Clôture refusée');
expect(fixture.componentInstance.state()?.phase).toBe('AWAITING_CLOSURE');
});
});
@@ -72,6 +72,11 @@
.form-actions {
margin-top: 28px;
}
.core-field .field-error {
margin: -14px 0 0;
color: var(--danger);
font-size: 0.75rem;
}
@media (min-width: 700px) {
.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -38,7 +38,8 @@
</div>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Appartement</mat-label
><mat-select
id="apartmentId"
@@ -50,11 +51,13 @@
<mat-option [value]="apartment.id">{{ apartment.name }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (fieldError('apartmentId')) {
<mat-error>{{ fieldError('apartmentId') }}</mat-error>
<p class="field-error" role="alert">{{ fieldError('apartmentId') }}</p>
}
</mat-form-field>
<mat-form-field appearance="outline"
</div>
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Locataires</mat-label
><mat-select
id="tenantIds"
@@ -68,10 +71,11 @@
>
}
</mat-select>
</mat-form-field>
@if (fieldError('tenantIds')) {
<mat-error>{{ fieldError('tenantIds') }}</mat-error>
<p class="field-error" role="alert">{{ fieldError('tenantIds') }}</p>
}
</mat-form-field>
</div>
</div>
</section>
<section class="form-section">
@@ -83,7 +87,8 @@
</div>
</div>
<div class="form-grid three-columns">
<mat-form-field appearance="outline"
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Type de bail</mat-label
><mat-select
id="leaseType"
@@ -93,11 +98,13 @@
<mat-option value="STANDARD">Location meublée classique</mat-option>
<mat-option value="STUDENT">Location meublée étudiante</mat-option>
</mat-select>
</mat-form-field>
@if (fieldError('leaseType')) {
<mat-error>{{ fieldError('leaseType') }}</mat-error>
<p class="field-error" role="alert">{{ fieldError('leaseType') }}</p>
}
</mat-form-field>
<mat-form-field appearance="outline"
</div>
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Date d'entrée</mat-label
><input
matInput
@@ -106,10 +113,11 @@
[formField]="leaseForm.startDate"
(change)="fieldWasChanged('startDate')"
/>
</mat-form-field>
@if (fieldError('startDate')) {
<mat-error>{{ fieldError('startDate') }}</mat-error>
<p class="field-error" role="alert">{{ fieldError('startDate') }}</p>
}
</mat-form-field>
</div>
<div class="lease-duration" aria-live="polite">
<span id="lease-end-date-label">Date de fin calculée</span>
<output aria-labelledby="lease-end-date-label">
@@ -120,7 +128,8 @@
@if (overlapWarning()) {
<p class="lease-warning" role="note">{{ overlapWarning() }}</p>
}
<mat-form-field appearance="outline"
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Loyer mensuel</mat-label
><input
matInput
@@ -130,11 +139,13 @@
step="0.01"
(change)="fieldWasChanged('monthlyRent')"
/><span matTextSuffix>€</span>
</mat-form-field>
@if (fieldError('monthlyRent')) {
<mat-error>{{ fieldError('monthlyRent') }}</mat-error>
<p class="field-error" role="alert">{{ fieldError('monthlyRent') }}</p>
}
</mat-form-field>
<mat-form-field appearance="outline"
</div>
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Charges mensuelles</mat-label
><input
matInput
@@ -144,11 +155,13 @@
step="0.01"
(change)="fieldWasChanged('monthlyCharges')"
/><span matTextSuffix>€</span>
</mat-form-field>
@if (fieldError('monthlyCharges')) {
<mat-error>{{ fieldError('monthlyCharges') }}</mat-error>
<p class="field-error" role="alert">{{ fieldError('monthlyCharges') }}</p>
}
</mat-form-field>
<mat-form-field appearance="outline"
</div>
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Dépôt de garantie</mat-label
><input
matInput
@@ -158,11 +171,13 @@
step="0.01"
(change)="fieldWasChanged('securityDeposit')"
/><span matTextSuffix>€</span>
</mat-form-field>
@if (fieldError('securityDeposit')) {
<mat-error>{{ fieldError('securityDeposit') }}</mat-error>
<p class="field-error" role="alert">{{ fieldError('securityDeposit') }}</p>
}
</mat-form-field>
<mat-form-field appearance="outline"
</div>
<div class="core-field">
<mat-form-field appearance="outline"
><mat-label>Mode de récupération des charges</mat-label
><mat-select
id="chargeMethod"
@@ -185,10 +200,11 @@
step="1"
(change)="fieldWasChanged('dueDay')"
/><mat-hint>Entre le 1er et le 28 du mois</mat-hint>
</mat-form-field>
@if (fieldError('dueDay')) {
<mat-error>{{ fieldError('dueDay') }}</mat-error>
<p class="field-error" role="alert">{{ fieldError('dueDay') }}</p>
}
</mat-form-field>
</div>
@if (leaseModel().chargeMethod === 'FLAT_RATE') {
<mat-checkbox id="indexFlatRate" [formField]="leaseForm.indexFlatRate">
Indexer le forfait de charges avec le loyer
@@ -174,6 +174,7 @@ describe('LeaseForm', () => {
]);
fixture.detectChanges();
await new Promise<void>((resolve) => queueMicrotask(resolve));
fixture.detectChanges();
expect(fixture.componentInstance.leaseModel().monthlyCharges).toBe(50);
expect(fixture.nativeElement.textContent).toContain('charges mensuelles doivent être');
@@ -182,6 +183,7 @@ describe('LeaseForm', () => {
fixture.componentRef.setInput('saveFieldErrors', [{ field: 'dueDay', code: 'max' }]);
fixture.detectChanges();
await new Promise<void>((resolve) => queueMicrotask(resolve));
fixture.detectChanges();
expect(document.activeElement?.id).toBe('dueDay');
});
@@ -19,7 +19,12 @@ import { RentDue } from '../../services/lease.service';
import { saveFailure } from '../../services/http-error-message';
import { formatEuro } from '../../services/rent-payment-format';
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
import {
isRentPaymentConflict,
isRentPaymentDataStale,
RENT_PAYMENT_CONFLICT_MESSAGE,
RENT_PAYMENT_DATA_STALE,
} from '../rent-payment-dialog-result';
export interface EligibleRentDue {
rentDue: RentDue;
@@ -136,7 +141,13 @@ export class RentPaymentAllocationDialog {
const knownErrors = failure.fieldErrors.filter((fieldError) =>
this.isKnownFieldError(fieldError.field, fieldError.code),
);
this.error.set(knownErrors.length === failure.fieldErrors.length ? '' : failure.message);
this.error.set(
knownErrors.length > 0 && knownErrors.length === failure.fieldErrors.length
? ''
: isRentPaymentConflict(error)
? RENT_PAYMENT_CONFLICT_MESSAGE
: failure.message,
);
this.fieldErrorFields.set(new Set(knownErrors.map((fieldError) => fieldError.field)));
for (const fieldError of knownErrors) {
const control = this.form.get(fieldError.field);
@@ -1,6 +1,8 @@
import { RentPayment } from '../services/rent-payment.service';
export const RENT_PAYMENT_DATA_STALE = 'RENT_PAYMENT_DATA_STALE';
export const RENT_PAYMENT_CONFLICT_MESSAGE =
'Cette action n’est plus possible avec les données actuelles. Actualisez-les puis réessayez.';
const CONCURRENT_CHANGE = 'concurrent_change';
export type RentPaymentDialogResult = RentPayment | typeof RENT_PAYMENT_DATA_STALE;
@@ -18,3 +20,7 @@ export function isRentPaymentDataStale(error: unknown): boolean {
error.error.code === CONCURRENT_CHANGE
);
}
export function isRentPaymentConflict(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'status' in error && error.status === 409;
}
@@ -2,7 +2,7 @@ import { Type } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { of, throwError } from 'rxjs';
import { RENT_PAYMENT_DATA_STALE } from './rent-payment-dialog-result';
import { RENT_PAYMENT_CONFLICT_MESSAGE, RENT_PAYMENT_DATA_STALE } from './rent-payment-dialog-result';
import { RentPaymentAllocationDialog } from './rent-payment-allocation-dialog/rent-payment-allocation-dialog';
import { RentPaymentRefundDialog } from './rent-payment-refund-dialog/rent-payment-refund-dialog';
import { RentPaymentReversalDialog } from './rent-payment-reversal-dialog/rent-payment-reversal-dialog';
@@ -80,10 +80,31 @@ describe('rent payment dialogs', () => {
component.submit();
expect(component.form.controls.amount.value).toBe(0.001);
expect(component.error()).toBe('Échéance déjà réglée');
expect(component.error()).toBe(RENT_PAYMENT_CONFLICT_MESSAGE);
expect(dialogRef.close).not.toHaveBeenCalled();
});
it('keeps non-concurrent conflicts visible for every payment action', () => {
payments.refund.mockReturnValue(throwError(() => ({ status: 409, error: { message: 'detail' } })));
const refund = create(RentPaymentRefundDialog, payment({ unallocatedBalance: 100 }));
refund.submit();
expect(refund.error()).toBe(RENT_PAYMENT_CONFLICT_MESSAGE);
payments.reverse.mockReturnValue(throwError(() => ({ status: 409, error: { message: 'detail' } })));
const reversal = create(RentPaymentReversalDialog, payment());
reversal.form.controls.reason.setValue('Erreur de saisie');
reversal.submit();
expect(reversal.error()).toBe(RENT_PAYMENT_CONFLICT_MESSAGE);
payments.allocate.mockReturnValue(throwError(() => ({ status: 409, error: { message: 'detail' } })));
const allocation = create(RentPaymentAllocationDialog, {
payment: payment({ unallocatedBalance: 100 }),
eligibleRentDues: [{ rentDue: due(), label: 'Juillet' }],
});
allocation.submit();
expect(allocation.error()).toBe(RENT_PAYMENT_CONFLICT_MESSAGE);
});
it('maps a known server field error and clears it when that field changes', () => {
payments.refund.mockReturnValue(
throwError(() => ({
@@ -16,7 +16,12 @@ import { finalize } from 'rxjs';
import { saveFailure } from '../../services/http-error-message';
import { browserTimeZone, formatEuro, localIsoDate } from '../../services/rent-payment-format';
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
import {
isRentPaymentConflict,
isRentPaymentDataStale,
RENT_PAYMENT_CONFLICT_MESSAGE,
RENT_PAYMENT_DATA_STALE,
} from '../rent-payment-dialog-result';
@Component({
selector: 'app-rent-payment-refund-dialog',
@@ -92,7 +97,13 @@ export class RentPaymentRefundDialog {
const knownErrors = failure.fieldErrors.filter((fieldError) =>
this.isKnownFieldError(fieldError.field, fieldError.code),
);
this.error.set(knownErrors.length === failure.fieldErrors.length ? '' : failure.message);
this.error.set(
knownErrors.length > 0 && knownErrors.length === failure.fieldErrors.length
? ''
: isRentPaymentConflict(error)
? RENT_PAYMENT_CONFLICT_MESSAGE
: failure.message,
);
this.fieldErrorFields.set(new Set(knownErrors.map((fieldError) => fieldError.field)));
for (const fieldError of knownErrors) {
const control = this.form.get(fieldError.field);
@@ -15,7 +15,12 @@ import { finalize } from 'rxjs';
import { saveFailure } from '../../services/http-error-message';
import { browserTimeZone, formatEuro } from '../../services/rent-payment-format';
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
import {
isRentPaymentConflict,
isRentPaymentDataStale,
RENT_PAYMENT_CONFLICT_MESSAGE,
RENT_PAYMENT_DATA_STALE,
} from '../rent-payment-dialog-result';
@Component({
selector: 'app-rent-payment-reversal-dialog',
@@ -87,7 +92,13 @@ export class RentPaymentReversalDialog {
const knownErrors = failure.fieldErrors.filter((fieldError) =>
this.isKnownFieldError(fieldError.field, fieldError.code),
);
this.error.set(knownErrors.length === failure.fieldErrors.length ? '' : failure.message);
this.error.set(
knownErrors.length > 0 && knownErrors.length === failure.fieldErrors.length
? ''
: isRentPaymentConflict(error)
? RENT_PAYMENT_CONFLICT_MESSAGE
: failure.message,
);
this.fieldErrorFields.set(new Set(knownErrors.map((fieldError) => fieldError.field)));
for (const fieldError of knownErrors) {
const control = this.form.get(fieldError.field);
@@ -18,7 +18,12 @@ import { RentDue } from '../../services/lease.service';
import { saveFailure } from '../../services/http-error-message';
import { browserTimeZone, formatEuro, localIsoDate } from '../../services/rent-payment-format';
import { RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
import {
isRentPaymentConflict,
isRentPaymentDataStale,
RENT_PAYMENT_CONFLICT_MESSAGE,
RENT_PAYMENT_DATA_STALE,
} from '../rent-payment-dialog-result';
export interface RentPaymentValidationDialogData {
rentDue: RentDue;
@@ -109,7 +114,13 @@ export class RentPaymentValidationDialog {
const knownErrors = failure.fieldErrors.filter((fieldError) =>
this.isKnownFieldError(fieldError.field, fieldError.code),
);
this.error.set(knownErrors.length === failure.fieldErrors.length ? '' : failure.message);
this.error.set(
knownErrors.length > 0 && knownErrors.length === failure.fieldErrors.length
? ''
: isRentPaymentConflict(error)
? RENT_PAYMENT_CONFLICT_MESSAGE
: failure.message,
);
this.fieldErrorFields.set(new Set(knownErrors.map((fieldError) => fieldError.field)));
for (const fieldError of knownErrors) {
const control = this.form.get(fieldError.field);