Preserve actionable form errors
This commit is contained in:
1 parent
fe36d6cae4
commit
3a8ef0ae0b
17 files changed
+166
-63
No files matched your search
+1
-8
@@ -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) {
|
||||||
|
|||||||
+1
-1
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -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(() => ({
|
||||||
|
|||||||
+13
-2
@@ -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);
|
||||||
|
|||||||
+13
-2
@@ -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);
|
||||||
|
|||||||
+13
-2
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user