From ac5a7942c96c16bfa4bdc453bb8c5719a9851bb9 Mon Sep 17 00:00:00 2001 From: Nessar Date: Fri, 17 Jul 2026 17:32:40 +0200 Subject: [PATCH] feat: refactor forms to use reactive forms with signals for improved state management --- .../apartment-form/apartment-form.html | 25 +++---- .../apartment-form/apartment-form.spec.ts | 27 ++++--- .../apartment-form/apartment-form.ts | 47 ++++++++---- .../guarantor-form/guarantor-form.html | 34 +++------ .../guarantor-form/guarantor-form.spec.ts | 29 ++++---- .../guarantor-form/guarantor-form.ts | 73 +++++++++++++------ .../app/components/lease-form/lease-form.html | 52 +++++-------- .../components/lease-form/lease-form.spec.ts | 18 +++-- .../app/components/lease-form/lease-form.ts | 65 +++++++++++------ .../components/tenant-form/tenant-form.html | 34 +++------ .../tenant-form/tenant-form.spec.ts | 28 ++++--- .../app/components/tenant-form/tenant-form.ts | 62 +++++++++++----- 12 files changed, 281 insertions(+), 213 deletions(-) diff --git a/frontend/src/app/components/apartment-form/apartment-form.html b/frontend/src/app/components/apartment-form/apartment-form.html index 7eba1d4..beb5b5a 100644 --- a/frontend/src/app/components/apartment-form/apartment-form.html +++ b/frontend/src/app/components/apartment-form/apartment-form.html @@ -16,6 +16,7 @@
Prix d'acquisition€ Description ou notes diff --git a/frontend/src/app/components/apartment-form/apartment-form.spec.ts b/frontend/src/app/components/apartment-form/apartment-form.spec.ts index 4217b79..8900d32 100644 --- a/frontend/src/app/components/apartment-form/apartment-form.spec.ts +++ b/frontend/src/app/components/apartment-form/apartment-form.spec.ts @@ -15,8 +15,8 @@ describe('ApartmentForm', () => { fixture.detectChanges(); const form = fixture.componentInstance; - expect(form.name()).toBe('T2 Paris 15'); - expect(form.purchasePrice()).toBe(250000); + expect(form.apartmentModel().name).toBe('T2 Paris 15'); + expect(form.apartmentModel().purchasePrice).toBe(250000); }); it('shows a validation error instead of emitting an incomplete apartment', () => { @@ -36,10 +36,12 @@ describe('ApartmentForm', () => { const form = fixture.componentInstance; const saved = vi.fn(); form.saved.subscribe(saved); - form.name.set(' T2 Paris 15 '); - form.address.set(' 45 rue des Entrepreneurs '); - form.purchasePrice.set(250000); - form.description.set(' Cinquième étage '); + form.apartmentModel.set({ + name: ' T2 Paris 15 ', + address: ' 45 rue des Entrepreneurs ', + purchasePrice: 250000, + description: ' Cinquième étage ', + }); form.save(); @@ -70,15 +72,18 @@ describe('ApartmentForm', () => { fixture.detectChanges(); await fixture.whenStable(); const form = fixture.componentInstance; - form.name.set('T2 rénové'); - form.address.set('45 rue des Entrepreneurs'); - form.purchasePrice.set(265000); + form.apartmentModel.set({ + name: 'T2 rénové', + address: '45 rue des Entrepreneurs', + purchasePrice: 265000, + description: '', + }); fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.'); fixture.detectChanges(); await fixture.whenStable(); - expect(fixture.nativeElement.querySelector('input[name=name]').value).toBe('T2 rénové'); - expect(fixture.nativeElement.querySelector('input[name=address]').value).toBe( + expect(fixture.nativeElement.querySelector('input#name').value).toBe('T2 rénové'); + expect(fixture.nativeElement.querySelector('input#address').value).toBe( '45 rue des Entrepreneurs', ); expect(fixture.nativeElement.textContent).toContain( diff --git a/frontend/src/app/components/apartment-form/apartment-form.ts b/frontend/src/app/components/apartment-form/apartment-form.ts index b573e94..858b270 100644 --- a/frontend/src/app/components/apartment-form/apartment-form.ts +++ b/frontend/src/app/components/apartment-form/apartment-form.ts @@ -1,5 +1,6 @@ import { Component, effect, input, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { FormRoot, FormField, form, required, min } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -10,6 +11,8 @@ import { Apartment } from '../../services/apartment.service'; selector: 'app-apartment-form', imports: [ FormsModule, + FormRoot, + FormField, MatButtonModule, MatFormFieldModule, MatInputModule, @@ -25,10 +28,20 @@ export class ApartmentForm { readonly closed = output(); readonly saved = output(); - readonly name = signal(''); - readonly address = signal(''); - readonly purchasePrice = signal(0); - readonly description = signal(''); + readonly apartmentModel = signal({ + name: '', + address: '', + purchasePrice: 0, + description: '', + }); + + readonly apartmentForm = form(this.apartmentModel, (f) => { + required(f.name, { message: 'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).' }); + required(f.address, { message: 'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).' }); + required(f.purchasePrice, { message: 'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).' }); + min(f.purchasePrice, 1, { message: 'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).' }); + }); + readonly error = signal(''); constructor() { @@ -37,24 +50,30 @@ export class ApartmentForm { save(): void { if (this.saving()) return; - if (!this.name().trim() || !this.address().trim() || this.purchasePrice() <= 0) { - this.error.set('Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).'); + this.apartmentForm().markAsTouched(); + if (this.apartmentForm().invalid()) { + const firstError = this.apartmentForm().errorSummary()[0]; + this.error.set(firstError?.message ?? 'Veuillez corriger les erreurs.'); return; } this.saved.emit({ - name: this.name().trim(), - address: this.address().trim(), - purchasePrice: this.purchasePrice(), - description: this.description().trim(), + id: this.apartment()?.id, + name: this.apartmentModel().name.trim(), + address: this.apartmentModel().address.trim(), + purchasePrice: this.apartmentModel().purchasePrice, + description: this.apartmentModel().description.trim(), }); } private resetForm(apartment: Apartment | null): void { - this.name.set(apartment?.name ?? ''); - this.address.set(apartment?.address ?? ''); - this.purchasePrice.set(apartment?.purchasePrice ?? 0); - this.description.set(apartment?.description ?? ''); + this.apartmentModel.set({ + id: apartment?.id, + name: apartment?.name ?? '', + address: apartment?.address ?? '', + purchasePrice: apartment?.purchasePrice ?? 0, + description: apartment?.description ?? '', + }); this.error.set(''); } } diff --git a/frontend/src/app/components/guarantor-form/guarantor-form.html b/frontend/src/app/components/guarantor-form/guarantor-form.html index 40e12d7..575de8d 100644 --- a/frontend/src/app/components/guarantor-form/guarantor-form.html +++ b/frontend/src/app/components/guarantor-form/guarantor-form.html @@ -1,37 +1,30 @@
Prénom du garant Nom du garant E-mail du garant Adresse du garant
diff --git a/frontend/src/app/components/guarantor-form/guarantor-form.spec.ts b/frontend/src/app/components/guarantor-form/guarantor-form.spec.ts index 864a388..51c1f36 100644 --- a/frontend/src/app/components/guarantor-form/guarantor-form.spec.ts +++ b/frontend/src/app/components/guarantor-form/guarantor-form.spec.ts @@ -5,13 +5,12 @@ import { GuarantorForm } from './guarantor-form'; describe('GuarantorForm', () => { it('marks an invalid form as touched without submitting it', () => { const fixture = TestBed.createComponent(GuarantorForm); - const markAllAsTouched = vi.fn(); const submitted = vi.fn(); fixture.componentInstance.submitted.subscribe(submitted); - fixture.componentInstance.save({ invalid: true, control: { markAllAsTouched } } as never); + fixture.componentInstance.save(); - expect(markAllAsTouched).toHaveBeenCalled(); + expect(fixture.componentInstance.guarantorForm().touched()).toBe(true); expect(submitted).not.toHaveBeenCalled(); }); @@ -19,20 +18,24 @@ describe('GuarantorForm', () => { const fixture = TestBed.createComponent(GuarantorForm); const component = fixture.componentInstance; const submitted = vi.fn(); - const resetForm = vi.fn(); component.submitted.subscribe(submitted); - component.firstName.set(' Alex '); - component.lastName.set(' Durand '); - component.email.set(' alex@example.com '); - component.phone.set(' 0600000000 '); - component.address.set(' 1 rue de Paris '); + component.guarantorModel.set({ + firstName: ' Alex ', + lastName: ' Durand ', + email: ' alex@example.com ', + phone: ' 0600000000 ', + address: ' 1 rue de Paris ', + }); - component.save({ invalid: false, resetForm } as never); + component.save(); expect(submitted).toHaveBeenCalledWith({ - firstName: 'Alex', lastName: 'Durand', email: 'alex@example.com', phone: '0600000000', address: '1 rue de Paris', + firstName: 'Alex', + lastName: 'Durand', + email: 'alex@example.com', + phone: '0600000000', + address: '1 rue de Paris', }); - expect(component.firstName()).toBe(''); - expect(resetForm).toHaveBeenCalled(); + expect(component.guarantorModel().firstName).toBe(''); }); }); diff --git a/frontend/src/app/components/guarantor-form/guarantor-form.ts b/frontend/src/app/components/guarantor-form/guarantor-form.ts index 4b5f61e..4410650 100644 --- a/frontend/src/app/components/guarantor-form/guarantor-form.ts +++ b/frontend/src/app/components/guarantor-form/guarantor-form.ts @@ -1,5 +1,6 @@ import { Component, output, signal } from '@angular/core'; -import { FormsModule, NgForm } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; +import { FormRoot, FormField, form, required, email } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -7,35 +8,65 @@ import { Guarantor } from '../../services/lease.service'; @Component({ selector: 'app-guarantor-form', - imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule], + imports: [ + FormsModule, + FormRoot, + FormField, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + ], templateUrl: './guarantor-form.html', styleUrl: './guarantor-form.css', }) export class GuarantorForm { readonly submitted = output(); - readonly firstName = signal(''); - readonly lastName = signal(''); - readonly email = signal(''); - readonly phone = signal(''); - readonly address = signal(''); - save(form: NgForm): void { - if (form.invalid) { - form.control.markAllAsTouched(); + readonly guarantorModel = signal({ + firstName: '', + lastName: '', + email: '', + phone: '', + address: '', + }); + + readonly guarantorForm = form(this.guarantorModel, (f) => { + required(f.firstName); + required(f.lastName); + required(f.email); + email(f.email); + }); + + save(): void { + this.guarantorModel.update((m) => ({ + firstName: m.firstName.trim(), + lastName: m.lastName.trim(), + email: m.email.trim(), + phone: m.phone.trim(), + address: m.address.trim(), + })); + + this.guarantorForm().markAsTouched(); + if (this.guarantorForm().invalid()) { return; } + + const modelVal = this.guarantorModel(); this.submitted.emit({ - firstName: this.firstName().trim(), - lastName: this.lastName().trim(), - email: this.email().trim(), - phone: this.phone().trim(), - address: this.address().trim(), + firstName: modelVal.firstName, + lastName: modelVal.lastName, + email: modelVal.email, + phone: modelVal.phone, + address: modelVal.address, }); - this.firstName.set(''); - this.lastName.set(''); - this.email.set(''); - this.phone.set(''); - this.address.set(''); - form.resetForm(); + + this.guarantorModel.set({ + firstName: '', + lastName: '', + email: '', + phone: '', + address: '', + }); + this.guarantorForm().reset(); } } diff --git a/frontend/src/app/components/lease-form/lease-form.html b/frontend/src/app/components/lease-form/lease-form.html index 4c2c0ef..3f7c799 100644 --- a/frontend/src/app/components/lease-form/lease-form.html +++ b/frontend/src/app/components/lease-form/lease-form.html @@ -18,9 +18,8 @@ @if (saveError()) { @@ -45,10 +44,8 @@ AppartementSélectionnez un appartement @for (apartment of apartments(); track apartment.id) { {{ apartment.name }} @@ -58,11 +55,9 @@ Locataires @for (tenant of tenants(); track tenant.id) { Date de début @@ -99,13 +92,11 @@ >Date de fin @@ -114,11 +105,8 @@ >€
€ € diff --git a/frontend/src/app/components/lease-form/lease-form.spec.ts b/frontend/src/app/components/lease-form/lease-form.spec.ts index e022d24..7be04af 100644 --- a/frontend/src/app/components/lease-form/lease-form.spec.ts +++ b/frontend/src/app/components/lease-form/lease-form.spec.ts @@ -9,17 +9,19 @@ describe('LeaseForm', () => { fixture.componentRef.setInput('apartments', []); fixture.componentRef.setInput('tenants', []); const component = fixture.componentInstance; - component.apartmentId.set('apartment-1'); - component.tenantIds.set(['tenant-1']); - component.startDate.set(new Date(2026, 8, 1)); - component.endDate.set(new Date(2027, 7, 31)); - component.monthlyRent.set(850); - component.monthlyCharges.set(50); - component.securityDeposit.set(850); + component.leaseModel.set({ + apartmentId: 'apartment-1', + tenantIds: ['tenant-1'], + startDate: new Date(2026, 8, 1), + endDate: new Date(2027, 7, 31), + monthlyRent: 850, + monthlyCharges: 50, + securityDeposit: 850, + }); let startDate = ''; component.submitted.subscribe((lease) => (startDate = lease.startDate)); - component.save({ invalid: false } as never); + component.save(); expect(startDate).toBe('2026-09-01'); }); diff --git a/frontend/src/app/components/lease-form/lease-form.ts b/frontend/src/app/components/lease-form/lease-form.ts index f47df08..f3398ac 100644 --- a/frontend/src/app/components/lease-form/lease-form.ts +++ b/frontend/src/app/components/lease-form/lease-form.ts @@ -1,5 +1,6 @@ import { Component, input, output, signal } from '@angular/core'; -import { FormsModule, NgForm } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; +import { FormRoot, FormField, form, required, min } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -15,6 +16,8 @@ import { GuarantorForm } from '../guarantor-form/guarantor-form'; selector: 'app-lease-form', imports: [ FormsModule, + FormRoot, + FormField, MatButtonModule, MatDatepickerModule, MatFormFieldModule, @@ -34,24 +37,39 @@ export class LeaseForm { readonly saveError = input(''); readonly cancelled = output(); readonly submitted = output(); - readonly apartmentId = signal(''); - readonly tenantIds = signal([]); - readonly startDate = signal(null); - readonly endDate = signal(null); - readonly monthlyRent = signal(0); - readonly monthlyCharges = signal(0); - readonly securityDeposit = signal(0); + + readonly leaseModel = signal({ + apartmentId: '', + tenantIds: [] as string[], + startDate: null as Date | null, + endDate: null as Date | null, + monthlyRent: 0, + monthlyCharges: 0, + securityDeposit: 0, + }); + + readonly leaseForm = form(this.leaseModel, (f) => { + required(f.apartmentId); + required(f.tenantIds); + required(f.startDate); + required(f.endDate); + required(f.monthlyRent); + min(f.monthlyRent, 1); + required(f.monthlyCharges); + required(f.securityDeposit); + }); + readonly guarantors = signal([]); readonly error = signal(''); readonly dateFilter = (d: Date | null): boolean => { if (!d) return false; - const aptId = this.apartmentId(); + const aptId = this.leaseModel().apartmentId; if (!aptId) return true; - + const time = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); - - const overlapping = this.leases().some(lease => { + + const overlapping = this.leases().some((lease) => { if (lease.apartmentId !== aptId) return false; const [startYear, startMonth, startDay] = lease.startDate.split('-').map(Number); const [endYear, endMonth, endDay] = lease.endDate.split('-').map(Number); @@ -72,25 +90,26 @@ export class LeaseForm { ); } - save(form: NgForm): void { + save(): void { if (this.saving()) return; - if (form.invalid) { - form.control.markAllAsTouched(); + this.leaseForm().markAsTouched(); + if (this.leaseForm().invalid()) { return; } - if (this.endDate()! < this.startDate()!) { + const modelVal = this.leaseModel(); + if (modelVal.endDate! < modelVal.startDate!) { this.error.set('La date de fin doit être postérieure à la date de début.'); return; } this.submitted.emit({ - apartmentId: this.apartmentId(), - tenantIds: this.tenantIds(), + apartmentId: modelVal.apartmentId, + tenantIds: modelVal.tenantIds, guarantors: this.guarantors(), - startDate: this.toIsoDate(this.startDate()!), - endDate: this.toIsoDate(this.endDate()!), - monthlyRent: this.monthlyRent(), - monthlyCharges: this.monthlyCharges(), - securityDeposit: this.securityDeposit(), + startDate: this.toIsoDate(modelVal.startDate!), + endDate: this.toIsoDate(modelVal.endDate!), + monthlyRent: modelVal.monthlyRent, + monthlyCharges: modelVal.monthlyCharges, + securityDeposit: modelVal.securityDeposit, status: 'ACTIVE', }); } diff --git a/frontend/src/app/components/tenant-form/tenant-form.html b/frontend/src/app/components/tenant-form/tenant-form.html index 85101ec..db6acef 100644 --- a/frontend/src/app/components/tenant-form/tenant-form.html +++ b/frontend/src/app/components/tenant-form/tenant-form.html @@ -17,9 +17,8 @@ @if (saveError()) { @@ -34,29 +33,23 @@ >Prénom Nom E-mail Adresse complète
diff --git a/frontend/src/app/components/tenant-form/tenant-form.spec.ts b/frontend/src/app/components/tenant-form/tenant-form.spec.ts index e8f1238..eeaf301 100644 --- a/frontend/src/app/components/tenant-form/tenant-form.spec.ts +++ b/frontend/src/app/components/tenant-form/tenant-form.spec.ts @@ -6,13 +6,17 @@ describe('TenantForm', () => { it('emits a trimmed tenant through its small interface', () => { const fixture = TestBed.createComponent(TenantForm); const component = fixture.componentInstance; - component.firstName.set(' Camille '); - component.lastName.set(' Martin '); - component.email.set(' camille@example.com '); + component.tenantModel.set({ + firstName: ' Camille ', + lastName: ' Martin ', + email: ' camille@example.com ', + phone: '0600000000', + address: '', + }); let firstName = ''; component.submitted.subscribe((request) => (firstName = request.tenant.firstName)); - component.save({ invalid: false } as never); + component.save(); expect(firstName).toBe('Camille'); }); @@ -20,17 +24,23 @@ describe('TenantForm', () => { it('loads an existing tenant and leaves invalid data untouched', () => { const fixture = TestBed.createComponent(TenantForm); fixture.componentRef.setInput('tenant', { - id: 'tenant-1', firstName: 'Camille', lastName: 'Martin', email: 'camille@example.com', phone: '0600000000', address: '1 rue de Paris', + id: 'tenant-1', + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '0600000000', + address: '1 rue de Paris', }); fixture.detectChanges(); - const markAllAsTouched = vi.fn(); const submitted = vi.fn(); fixture.componentInstance.submitted.subscribe(submitted); - fixture.componentInstance.save({ invalid: true, control: { markAllAsTouched } } as never); + // Make the form invalid (invalid email) + fixture.componentInstance.tenantModel.update((t) => ({ ...t, email: 'invalid-email' })); + fixture.componentInstance.save(); - expect(fixture.componentInstance.firstName()).toBe('Camille'); - expect(markAllAsTouched).toHaveBeenCalled(); + expect(fixture.componentInstance.tenantModel().firstName).toBe('Camille'); + expect(fixture.componentInstance.tenantForm().touched()).toBe(true); expect(submitted).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/app/components/tenant-form/tenant-form.ts b/frontend/src/app/components/tenant-form/tenant-form.ts index 737138b..116471c 100644 --- a/frontend/src/app/components/tenant-form/tenant-form.ts +++ b/frontend/src/app/components/tenant-form/tenant-form.ts @@ -1,5 +1,6 @@ import { Component, effect, input, output, signal } from '@angular/core'; -import { FormsModule, NgForm } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; +import { FormRoot, FormField, form, required, email } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -15,6 +16,8 @@ export interface TenantSaveRequest { selector: 'app-tenant-form', imports: [ FormsModule, + FormRoot, + FormField, MatButtonModule, MatFormFieldModule, MatInputModule, @@ -30,40 +33,61 @@ export class TenantForm { readonly cancelled = output(); readonly submitted = output(); - readonly firstName = signal(''); - readonly lastName = signal(''); - readonly email = signal(''); - readonly phone = signal(''); - readonly address = signal(''); + readonly tenantModel = signal({ + firstName: '', + lastName: '', + email: '', + phone: '', + address: '', + }); + + readonly tenantForm = form(this.tenantModel, (f) => { + required(f.firstName); + required(f.lastName); + required(f.email); + email(f.email); + }); constructor() { effect(() => this.fillForm(this.tenant())); } - save(form: NgForm): void { + save(): void { if (this.saving()) return; - if (form.invalid) { - form.control.markAllAsTouched(); + this.tenantModel.update((m) => ({ + firstName: m.firstName.trim(), + lastName: m.lastName.trim(), + email: m.email.trim(), + phone: m.phone.trim(), + address: m.address.trim(), + })); + + this.tenantForm().markAsTouched(); + if (this.tenantForm().invalid()) { return; } + const modelVal = this.tenantModel(); this.submitted.emit({ tenant: { - firstName: this.firstName().trim(), - lastName: this.lastName().trim(), - email: this.email().trim(), - phone: this.phone().trim(), - address: this.address().trim(), + firstName: modelVal.firstName, + lastName: modelVal.lastName, + email: modelVal.email, + phone: modelVal.phone, + address: modelVal.address, }, tenantId: this.tenant()?.id, }); } private fillForm(tenant: Tenant | null): void { - this.firstName.set(tenant?.firstName ?? ''); - this.lastName.set(tenant?.lastName ?? ''); - this.email.set(tenant?.email ?? ''); - this.phone.set(tenant?.phone ?? ''); - this.address.set(tenant?.address ?? ''); + this.tenantModel.set({ + id: tenant?.id, + firstName: tenant?.firstName ?? '', + lastName: tenant?.lastName ?? '', + email: tenant?.email ?? '', + phone: tenant?.phone ?? '', + address: tenant?.address ?? '', + }); } }