Validate private areas across apartment form (#44)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent b9f5540394
commit 72d3f0ccfa
9 files changed
+323 -44

No files matched your search

@@ -6,6 +6,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.Bui
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
@@ -106,6 +107,11 @@ record PrivateAreaRequest(
@NotNull PrivateArea.Family family,
@NotNull PrivateArea.Subtype subtype,
@NotBlank String label) {
@AssertTrue
boolean isSubtypeCompatibleWithFamily() {
return family == null || subtype == null || family.accepts(subtype);
}
PrivateArea toDomain() {
return new PrivateArea(id, family, subtype, label);
}
@@ -813,6 +813,37 @@ void apartmentRequestsExposeEveryCoreValidationFailureWithStableFieldsAndCodes()
jsonPath("$.errors[*].code", containsInAnyOrder("not_blank", "not_blank", "positive")));
}
@Test
void apartmentRequestsExposeEveryInvalidPrivateAreaWithIndexedStableFieldsAndCodes()
throws Exception {
mockMvc
.perform(
post("/api/apartments")
.with(asLandlord())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"name":"Studio","address":"1 rue du Test","purchasePrice":1,"privateAreas":[
{"id":"area-1","family":"ANNEXE","subtype":"BEDROOM","label":" "},
{"id":"area-2","family":"PIECE_DE_SERVICE","subtype":"BEDROOM","label":""}]}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(
jsonPath(
"$.errors[*].field",
containsInAnyOrder(
"privateAreas[0].label",
"privateAreas[0].subtypeCompatibleWithFamily",
"privateAreas[1].label",
"privateAreas[1].subtypeCompatibleWithFamily")))
.andExpect(
jsonPath(
"$.errors[*].code",
containsInAnyOrder("not_blank", "assert_true", "not_blank", "assert_true")));
}
@Test
void apartmentRequestsAcceptSubEuroPricesAndReturnTrimmedCoreFields() throws Exception {
when(apartments.create(eq(LANDLORD_ID), any()))
@@ -102,7 +102,9 @@
<app-apartment-legal-profile-form [legalProfileForm]="apartmentForm.legalProfile" />
<app-private-area-editor
[areas]="apartmentModel().privateAreas"
[serverErrors]="saveFieldErrors()"
(areasChanged)="setPrivateAreas($event)"
(fieldChanged)="fieldChanged.emit($event)"
/>
<div class="form-actions">
@@ -94,6 +94,68 @@ describe('ApartmentForm', () => {
expect(saved).toHaveBeenCalledWith(expect.objectContaining({ purchasePrice: 0.5 }));
});
it('keeps invalid private-area cards visible, shows their inline error, and focuses the label', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.detectChanges();
await fixture.whenStable();
const form = fixture.componentInstance;
const saved = vi.fn();
form.saved.subscribe(saved);
form.apartmentModel.set({
name: 'Studio',
address: '1 rue du Test',
purchasePrice: 1,
description: '',
privateAreas: [
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'OTHER', label: ' ' },
],
legalProfile: emptyApartmentLegalProfile(),
});
fixture.detectChanges();
form.save();
fixture.detectChanges();
await fixture.whenStable();
expect(saved).not.toHaveBeenCalled();
expect(form.error()).toContain('Renseignez le libellé de chaque espace privatif.');
expect(fixture.nativeElement.querySelectorAll('mat-card')).toHaveLength(1);
expect(fixture.nativeElement.textContent).toContain('Le libellé est obligatoire.');
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector('.private-area-label'),
);
});
it('forwards only the edited private-area server error path', async () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'privateAreas[0].label', code: 'not_blank' },
{ field: 'privateAreas[0].subtypeCompatibleWithFamily', code: 'assert_true' },
]);
fixture.detectChanges();
await fixture.whenStable();
const changed = vi.fn();
fixture.componentInstance.fieldChanged.subscribe(changed);
fixture.componentInstance.apartmentModel.set({
name: 'Studio',
address: '1 rue du Test',
purchasePrice: 1,
description: '',
privateAreas: [
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'OTHER', label: 'Séjour' },
],
legalProfile: emptyApartmentLegalProfile(),
});
fixture.detectChanges();
fixture.nativeElement
.querySelector('.private-area-label')
.dispatchEvent(new Event('input'));
expect(changed).toHaveBeenCalledTimes(1);
expect(changed).toHaveBeenCalledWith('privateAreas[0].label');
});
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;
@@ -1,4 +1,4 @@
import { Component, effect, input, output, signal } from '@angular/core';
import { Component, effect, input, output, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormRoot, FormField, form, max, min, required } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
@@ -49,6 +49,7 @@ export class ApartmentForm {
readonly closed = output<void>();
readonly saved = output<Apartment>();
readonly fieldChanged = output<string>();
readonly privateAreaEditor = viewChild(PrivateAreaEditor);
readonly apartmentModel = signal<Apartment>({
name: '',
@@ -91,13 +92,21 @@ export class ApartmentForm {
save(): void {
if (this.saving()) return;
this.apartmentForm().markAsTouched();
const privateAreasValid = this.privateAreaEditor()?.validate() ?? true;
if (
this.apartmentForm().invalid() ||
!this.apartmentModel().name.trim() ||
!this.apartmentModel().address.trim()
!this.apartmentModel().address.trim() ||
!privateAreasValid
) {
const firstError = this.apartmentForm().errorSummary()[0];
this.error.set(firstError?.message ?? this.coreFieldError() ?? nameRequiredError);
this.error.set(
firstError?.message ??
this.coreFieldError() ??
this.privateAreaEditor()?.validationMessage() ??
nameRequiredError,
);
if (!privateAreasValid) this.privateAreaEditor()?.focusFirstInvalid();
return;
}
@@ -55,3 +55,17 @@
.add-private-area {
margin-top: var(--space-4, 1rem);
}
.private-area-error {
margin: calc(var(--space-1, 0.25rem) * -1) 0 0;
color: var(--danger, #b42318);
font-size: 0.875rem;
}
.private-area-field {
min-width: 0;
}
.private-area-field mat-form-field {
width: 100%;
}
@@ -15,7 +15,7 @@
</div>
} @else {
<div class="private-areas">
@for (area of areas(); track area.id) {
@for (area of areas(); track area.id; let index = $index) {
<mat-card class="private-area">
<mat-card-content>
<div class="private-area-heading">
@@ -30,44 +30,73 @@
</button>
</div>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Famille</mat-label>
<mat-select
[value]="area.family"
(selectionChange)="updateArea(area.id, 'family', $event.value)"
>
@for (family of families; track family.value) {
<mat-option [value]="family.value">{{
family.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sous-type</mat-label>
<mat-select
[value]="area.subtype"
(selectionChange)="updateArea(area.id, 'subtype', $event.value)"
>
@for (
subtype of subtypesFor(area.family);
track subtype.value
) {
<mat-option [value]="subtype.value">{{
subtype.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Libellé</mat-label>
<input
matInput
[value]="area.label"
(input)="updateArea(area.id, 'label', $event)"
placeholder="Ex. Chambre 1"
/>
</mat-form-field>
<div class="private-area-field">
<mat-form-field appearance="outline">
<mat-label>Famille</mat-label>
<mat-select
required
[value]="area.family"
(selectionChange)="
updateArea(area.id, 'family', $event.value)
"
>
@for (family of families; track family.value) {
<mat-option [value]="family.value">{{
family.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
@if (familyError(index)) {
<p class="private-area-error" role="alert">
{{ familyError(index) }}
</p>
}
</div>
<div class="private-area-field">
<mat-form-field appearance="outline">
<mat-label>Sous-type</mat-label>
<mat-select
required
[value]="area.subtype"
(selectionChange)="
updateArea(area.id, 'subtype', $event.value)
"
>
@for (
subtype of subtypesFor(area.family);
track subtype.value
) {
<mat-option [value]="subtype.value">{{
subtype.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
@if (subtypeError(index)) {
<p class="private-area-error" role="alert">
{{ subtypeError(index) }}
</p>
}
</div>
<div class="private-area-field full-field">
<mat-form-field appearance="outline">
<mat-label>Libellé</mat-label>
<input
matInput
required
class="private-area-label"
[value]="area.label"
(input)="updateArea(area.id, 'label', $event)"
placeholder="Ex. Chambre 1"
/>
</mat-form-field>
@if (labelError(index, area)) {
<p class="private-area-error" role="alert">
{{ labelError(index, area) }}
</p>
}
</div>
</div>
</mat-card-content>
</mat-card>
@@ -39,6 +39,63 @@ describe('PrivateAreaEditor', () => {
expect(fixture.componentInstance.mainRoomCount()).toBe(1);
});
it('accepts no private areas and rejects a whitespace-only label inline', async () => {
const fixture = TestBed.createComponent(PrivateAreaEditor);
fixture.componentRef.setInput('areas', []);
expect(fixture.componentInstance.validate()).toBe(true);
fixture.componentRef.setInput('areas', [
{ id: 'area-1', family: 'PIECE_PRINCIPALE', subtype: 'OTHER', label: ' ' },
]);
fixture.detectChanges();
expect(fixture.componentInstance.validate()).toBe(false);
fixture.detectChanges();
expect(fixture.componentInstance.labelError(0, fixture.componentInstance.areas()[0])).toBe(
'Le libellé est obligatoire.',
);
expect(
(fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>(
'.private-area-label',
)?.required,
).toBe(true);
fixture.componentInstance.focusFirstInvalid();
await fixture.whenStable();
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector('.private-area-label'),
);
});
it('maps only known indexed server errors and emits the edited field path', () => {
const fixture = TestBed.createComponent(PrivateAreaEditor);
fixture.componentRef.setInput('areas', [
{ id: 'area-1', family: 'ANNEXE', subtype: 'BEDROOM', label: 'Cave' },
]);
fixture.componentRef.setInput('serverErrors', [
{ field: 'privateAreas[0].label', code: 'not_blank' },
{ field: 'privateAreas[0].subtypeCompatibleWithFamily', code: 'assert_true' },
{ field: 'privateAreas[0].label', code: 'unknown' },
]);
const fieldChanged = vi.fn();
fixture.componentInstance.fieldChanged.subscribe(fieldChanged);
const label = document.createElement('input');
label.value = 'Cave voûtée';
expect(fixture.componentInstance.labelError(0, fixture.componentInstance.areas()[0])).toBe(
'Le libellé est obligatoire.',
);
expect(fixture.componentInstance.subtypeError(0)).toBe(
'Le sous-type doit correspondre à la famille.',
);
fixture.componentInstance.updateArea('area-1', 'label', eventFor(label));
expect(fieldChanged).toHaveBeenCalledTimes(1);
expect(fieldChanged).toHaveBeenCalledWith('privateAreas[0].label');
});
it('filters subtypes and resets the subtype when the area family changes', () => {
const fixture = TestBed.createComponent(PrivateAreaEditor);
fixture.componentRef.setInput('areas', [
@@ -1,4 +1,4 @@
import { Component, computed, input, output } from '@angular/core';
import { Component, computed, ElementRef, inject, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -9,6 +9,7 @@ import {
PrivateAreaFamily,
PrivateAreaSubtype,
} from '../../services/apartment.service';
import { FieldValidationError } from '../../services/http-error-message';
const families: readonly { value: PrivateAreaFamily; label: string }[] = [
{ value: 'PIECE_PRINCIPALE', label: 'Pièce principale' },
@@ -45,9 +46,14 @@ const subtypeFamilies: Record<PrivateAreaFamily, readonly PrivateAreaSubtype[]>
styleUrl: './private-area-editor.css',
})
export class PrivateAreaEditor {
readonly areas = input.required<PrivateArea[]>();
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);
readonly areas = input<PrivateArea[]>([]);
readonly serverErrors = input<FieldValidationError[]>([]);
readonly areasChanged = output<PrivateArea[]>();
readonly fieldChanged = output<string>();
readonly families = families;
readonly showValidationErrors = signal(false);
readonly mainRoomCount = computed(
() => this.areas().filter(({ family }) => family === 'PIECE_PRINCIPALE').length,
);
@@ -72,6 +78,7 @@ export class PrivateAreaEditor {
value.target instanceof HTMLSelectElement
? value.target.value
: '';
const index = this.areas().findIndex((area) => area.id === id);
this.areasChanged.emit(
this.areas().map((area) =>
area.id === id
@@ -83,6 +90,12 @@ export class PrivateAreaEditor {
: area,
),
);
if (index !== -1) {
this.fieldChanged.emit(this.fieldPath(index, field));
if (field === 'family' || field === 'subtype') {
this.fieldChanged.emit(this.compatibilityFieldPath(index));
}
}
}
subtypesFor(family: PrivateAreaFamily): typeof subtypes {
@@ -92,4 +105,60 @@ export class PrivateAreaEditor {
removeArea(id: string): void {
this.areasChanged.emit(this.areas().filter((area) => area.id !== id));
}
validate(): boolean {
this.showValidationErrors.set(true);
return this.areas().every((area) => area.label.trim());
}
focusFirstInvalid(): void {
const index = this.areas().findIndex((area) => !area.label.trim());
if (index === -1) return;
queueMicrotask(() => {
const label = this.element.nativeElement.querySelectorAll('.private-area-label')[index];
if (label instanceof HTMLInputElement) label.focus();
});
}
labelError(index: number, area: PrivateArea): string {
if (this.showValidationErrors() && !area.label.trim()) {
return 'Le libellé est obligatoire.';
}
return this.hasServerError(this.fieldPath(index, 'label'), 'not_blank')
? 'Le libellé est obligatoire.'
: '';
}
familyError(index: number): string {
return this.hasServerError(this.fieldPath(index, 'family'), 'not_null')
? 'La famille est obligatoire.'
: '';
}
subtypeError(index: number): string {
if (this.hasServerError(this.fieldPath(index, 'subtype'), 'not_null')) {
return 'Le sous-type est obligatoire.';
}
return this.hasServerError(this.compatibilityFieldPath(index), 'assert_true')
? 'Le sous-type doit correspondre à la famille.'
: '';
}
validationMessage(): string {
return this.areas().some((area) => !area.label.trim())
? 'Renseignez le libellé de chaque espace privatif.'
: '';
}
private hasServerError(field: string, code: string): boolean {
return this.serverErrors().some((error) => error.field === field && error.code === code);
}
private fieldPath(index: number, field: keyof Omit<PrivateArea, 'id'>): string {
return `privateAreas[${index}].${field}`;
}
private compatibilityFieldPath(index: number): string {
return `privateAreas[${index}].subtypeCompatibleWithFamily`;
}
}