feat: refactor forms to use reactive forms with signals for improved state management

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 5d2abf4184
commit ac5a7942c9
12 files changed
+281 -213

No files matched your search

@@ -17,9 +17,8 @@
</header>
<form
class="entity-form"
#tenantForm="ngForm"
ngNativeValidate
(ngSubmit)="save(tenantForm)"
[formRoot]="tenantForm"
(ngSubmit)="save()"
[attr.aria-busy]="saving()"
>
@if (saveError()) {
@@ -34,29 +33,23 @@
><mat-label>Prénom</mat-label
><input
matInput
name="firstName"
[ngModel]="firstName()"
(ngModelChange)="firstName.set($event)"
required
id="firstName"
[formField]="tenantForm.firstName"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom</mat-label
><input
matInput
name="lastName"
[ngModel]="lastName()"
(ngModelChange)="lastName.set($event)"
required
id="lastName"
[formField]="tenantForm.lastName"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail</mat-label
><input
matInput
type="email"
name="email"
[ngModel]="email()"
(ngModelChange)="email.set($event)"
required
id="email"
[formField]="tenantForm.email"
autocomplete="email"
/></mat-form-field>
<mat-form-field appearance="outline"
@@ -64,19 +57,16 @@
><input
matInput
type="tel"
name="phone"
[ngModel]="phone()"
(ngModelChange)="phone.set($event)"
pattern="^(?:(?:0\d(?:[ .-]?\d){8})|(?:(?:\+|00)\d(?:[ .()-]?\d){7,14}))$"
id="phone"
[formField]="tenantForm.phone"
autocomplete="tel"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
name="address"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
id="address"
[formField]="tenantForm.address"
/></mat-form-field>
</div>
<div class="form-actions">
@@ -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();
});
});
@@ -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<void>();
readonly submitted = output<TenantSaveRequest>();
readonly firstName = signal('');
readonly lastName = signal('');
readonly email = signal('');
readonly phone = signal('');
readonly address = signal('');
readonly tenantModel = signal<Tenant>({
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 ?? '',
});
}
}