Validate private areas across apartment form (#44)
This commit is contained in:
1 parent
b9f5540394
commit
72d3f0ccfa
9 files changed
+323
-44
No files matched your search
+6
@@ -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);
|
||||
}
|
||||
|
||||
+31
@@ -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`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user