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