Align tenant and attachment validation (#48)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent a0f86bb511
commit 88d3f3c2a5
20 files changed
+550 -102

No files matched your search

@@ -111,7 +111,7 @@
tenant().lastName
"
(change)="selectFile($event)"
/>+ Ajouter une pièce jointe <small>· 250 Mo maximum</small></label
/>+ Ajouter une pièce jointe</label
>
@if (uploading()) {
<div class="upload-progress" role="status" aria-live="polite">
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { TenantCard } from './tenant-card';
describe('TenantCard', () => {
it('rejects files larger than the upload limit before emitting', () => {
it('forwards a selected file to the server-owned size guard', () => {
const fixture = TestBed.createComponent(TenantCard);
fixture.componentRef.setInput('tenant', {
id: 'tenant-1',
@@ -13,13 +13,14 @@ describe('TenantCard', () => {
address: '',
});
const file = new File(['identity'], 'archive.zip');
Object.defineProperty(file, 'size', { value: 250 * 1024 * 1024 + 1 });
const input = document.createElement('input');
Object.defineProperty(input, 'files', { value: { item: () => file } });
const requested = vi.fn();
fixture.componentInstance.attachmentUploadRequested.subscribe(requested);
fixture.componentInstance.selectFile({ target: input } as unknown as Event);
expect(fixture.componentInstance.uploadError()).toContain('250 Mo');
expect(requested).toHaveBeenCalledWith({ tenantId: 'tenant-1', file });
});
it('shows measurable progress and disables only its upload control', () => {
@@ -41,9 +41,7 @@ export class TenantCard {
const input = event.target as HTMLInputElement;
const file = this.uploading() || this.deleting() ? null : input.files?.item(0);
const tenantId = this.tenant().id;
if (file && file.size > 250 * 1024 * 1024) {
this.uploadError.set('Une pièce jointe ne peut pas dépasser 250 Mo.');
} else if (tenantId && file) {
if (tenantId && file) {
this.uploadError.set('');
this.attachmentUploadRequested.emit({ tenantId, file });
}
@@ -1,6 +1,12 @@
:host {
display: block;
}
.field-error {
display: block;
margin-top: -12px;
color: var(--danger);
font-size: 0.75rem;
}
@media (min-width: 1180px) {
.form-page {
max-width: 900px;
@@ -21,94 +21,98 @@
(ngSubmit)="save()"
[attr.aria-busy]="saving()"
>
@if (saveError()) {
@if (showSaveError()) {
<div class="alert-error" role="alert">{{ saveError() }}</div>
}
@if (validationError()) {
<div class="alert-error" role="alert">{{ validationError() }}</div>
}
<p class="visually-hidden" role="status" aria-live="polite">
{{ saving() ? 'Enregistrement en cours.' : '' }}
</p>
<fieldset class="busy-fieldset" [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Prénom</mat-label
><input
<mat-form-field appearance="outline">
<mat-label>Prénom</mat-label>
<input
matInput
id="firstName"
[formField]="tenantForm.firstName"
[attr.aria-describedby]="
tenantForm.firstName().touched() && tenantForm.firstName().invalid()
? 'firstName-error'
: null
"
(input)="changed('firstName')"
autocomplete="given-name"
/>
@if (tenantForm.firstName().touched() && tenantForm.firstName().invalid()) {
<mat-error id="firstName-error">Le prénom est obligatoire.</mat-error>
@if (firstNameError()) {
<span class="field-error" id="firstName-error" role="alert">{{
firstNameError()
}}</span>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom</mat-label
><input
<mat-form-field appearance="outline">
<mat-label>Nom</mat-label>
<input
matInput
id="lastName"
[formField]="tenantForm.lastName"
[attr.aria-describedby]="
tenantForm.lastName().touched() && tenantForm.lastName().invalid()
? 'lastName-error'
: null
"
(input)="changed('lastName')"
autocomplete="family-name"
/>
@if (tenantForm.lastName().touched() && tenantForm.lastName().invalid()) {
<mat-error id="lastName-error">Le nom est obligatoire.</mat-error>
@if (lastNameError()) {
<span class="field-error" id="lastName-error" role="alert">{{
lastNameError()
}}</span>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail</mat-label
><input
<mat-form-field appearance="outline">
<mat-label>E-mail</mat-label>
<input
matInput
type="email"
id="email"
[formField]="tenantForm.email"
(input)="changed('email')"
autocomplete="email"
[attr.aria-describedby]="
tenantForm.email().touched() && tenantForm.email().invalid()
? 'email-error'
: null
"
/>
@if (tenantForm.email().touched() && tenantForm.email().invalid()) {
<mat-error id="email-error">
@if (tenantForm.email().getError('required')) {
L’e-mail est obligatoire.
} @else {
Saisissez une adresse e-mail valide.
}
</mat-error>
@if (emailError()) {
<span class="field-error" id="email-error" role="alert">{{
emailError()
}}</span>
}
</mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone</mat-label
><input
<mat-form-field appearance="outline">
<mat-label>Téléphone (facultatif)</mat-label>
<input
matInput
type="tel"
id="phone"
[formField]="tenantForm.phone"
(input)="changed('phone')"
autocomplete="tel"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input matInput id="address" [formField]="tenantForm.address"
/></mat-form-field>
/>
@if (phoneError()) {
<span class="field-error" id="phone-error" role="alert">{{
phoneError()
}}</span>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Adresse complète (facultative)</mat-label>
<input
matInput
id="address"
[formField]="tenantForm.address"
(input)="changed('address')"
autocomplete="street-address"
/>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="cancelled.emit()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">
<span>
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer
}
</span>
<button mat-button type="button" (click)="cancelled.emit()">Annuler</button>
<button mat-flat-button type="submit" class="primary-action">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer
}
</button>
</div>
</fieldset>
@@ -44,7 +44,7 @@ describe('TenantForm', () => {
expect(submitted).not.toHaveBeenCalled();
});
it('shows actionable validation guidance linked to invalid fields', () => {
it('focuses the first invalid field and explains optional-phone formatting', async () => {
const fixture = TestBed.createComponent(TenantForm);
fixture.detectChanges();
fixture.componentInstance.tenantModel.set({
@@ -56,6 +56,7 @@ describe('TenantForm', () => {
});
fixture.componentInstance.save();
fixture.detectChanges();
await fixture.whenStable();
const firstNameInput = fixture.nativeElement.querySelector('#firstName');
const emailInput = fixture.nativeElement.querySelector('#email');
@@ -64,7 +65,49 @@ describe('TenantForm', () => {
expect(firstNameError?.textContent ?? '').toContain('Le prénom est obligatoire');
expect(emailError?.textContent ?? '').toContain('Saisissez une adresse e-mail valide');
expect(firstNameInput.getAttribute('aria-describedby')).toContain('firstName-error');
expect(emailInput.getAttribute('aria-describedby')).toContain('email-error');
expect(document.activeElement).toBe(firstNameInput);
expect(emailInput.id).toBe('email');
});
it('shows and clears only known server field errors without losing the form values', async () => {
const fixture = TestBed.createComponent(TenantForm);
const changed = vi.fn();
fixture.componentInstance.fieldChanged.subscribe(changed);
fixture.componentRef.setInput('tenant', {
id: 'tenant-1',
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '1234',
address: '1 rue de Paris',
});
fixture.componentRef.setInput('saveError', 'Veuillez corriger les champs signalés.');
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'phoneValid', code: 'assert_true' },
]);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.componentInstance.saveFieldErrors()).toEqual([
{ field: 'phoneValid', code: 'assert_true' },
]);
expect(fixture.componentInstance.phoneError()).toBe(
'Saisissez un numéro de téléphone valide.',
);
expect(fixture.nativeElement.textContent).toContain(
'Saisissez un numéro de téléphone valide.',
);
expect(fixture.nativeElement.textContent).not.toContain(
'Veuillez corriger les champs signalés.',
);
expect(document.activeElement?.id).toBe('phone');
expect(fixture.componentInstance.tenantModel().address).toBe('1 rue de Paris');
(fixture.nativeElement.querySelector('#phone') as HTMLInputElement).dispatchEvent(
new Event('input'),
);
expect(changed).toHaveBeenCalledWith('phone');
expect(changed).toHaveBeenCalledWith('phoneValid');
});
});
@@ -1,10 +1,12 @@
import { Component, effect, input, output, signal } from '@angular/core';
import { Component, effect, ElementRef, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormRoot, FormField, form, required, email } from '@angular/forms/signals';
import { email, FormField, form, FormRoot, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FieldValidationError } from '../../services/http-error-message';
import { isValidPhoneNumber } from '../../services/phone-number';
import { Tenant } from '../../services/tenant.service';
export interface TenantSaveRequest {
@@ -12,6 +14,8 @@ export interface TenantSaveRequest {
tenantId?: string;
}
type TenantField = 'firstName' | 'lastName' | 'email' | 'phone' | 'phoneValid' | 'address';
@Component({
selector: 'app-tenant-form',
imports: [
@@ -27,11 +31,15 @@ export interface TenantSaveRequest {
styleUrl: './tenant-form.css',
})
export class TenantForm {
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);
readonly tenant = input<Tenant | null>(null);
readonly saving = input(false);
readonly saveError = input('');
readonly saveFieldErrors = input<FieldValidationError[]>([]);
readonly cancelled = output<void>();
readonly submitted = output<TenantSaveRequest>();
readonly fieldChanged = output<TenantField>();
private previousServerErrorSignature = '';
readonly tenantModel = signal<Tenant>({
firstName: '',
@@ -40,46 +48,155 @@ export class TenantForm {
phone: '',
address: '',
});
readonly tenantForm = form(this.tenantModel, (f) => {
required(f.firstName);
required(f.lastName);
required(f.email);
email(f.email);
required(f.firstName, { message: 'Le prénom est obligatoire.' });
required(f.lastName, { message: 'Le nom est obligatoire.' });
required(f.email, { message: 'L’e-mail est obligatoire.' });
email(f.email, { message: 'Saisissez une adresse e-mail valide.' });
validate(f.phone, ({ value }) =>
isValidPhoneNumber(value())
? null
: { kind: 'phone', message: 'Saisissez un numéro de téléphone valide.' },
);
});
readonly validationError = signal('');
constructor() {
effect(() => this.fillForm(this.tenant()));
effect(() => {
this.fillForm(this.tenant());
this.validationError.set('');
});
effect(() => {
const signature = this.saveFieldErrors()
.map((error) => `${error.field}:${error.code}`)
.sort()
.join('|');
if (signature && signature !== this.previousServerErrorSignature) {
queueMicrotask(() => this.focusFirstInvalid());
}
this.previousServerErrorSignature = signature;
});
}
save(): void {
if (this.saving()) return;
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.tenantModel.update((model) => ({
...model,
firstName: model.firstName.trim(),
lastName: model.lastName.trim(),
email: model.email.trim(),
phone: model.phone.trim(),
address: model.address.trim(),
}));
this.tenantForm().markAsTouched();
if (this.tenantForm().invalid()) {
if (this.tenantForm().invalid() || this.hasClientError()) {
this.validationError.set(this.firstError() ?? 'Saisissez des informations valides.');
queueMicrotask(() => this.focusFirstInvalid());
return;
}
const modelVal = this.tenantModel();
this.validationError.set('');
const tenant = this.tenantModel();
this.submitted.emit({
tenant: {
firstName: modelVal.firstName,
lastName: modelVal.lastName,
email: modelVal.email,
phone: modelVal.phone,
address: modelVal.address,
firstName: tenant.firstName,
lastName: tenant.lastName,
email: tenant.email,
phone: tenant.phone,
address: tenant.address,
},
tenantId: this.tenant()?.id,
});
}
changed(field: TenantField): void {
this.validationError.set('');
this.fieldChanged.emit(field);
if (field === 'phone') this.fieldChanged.emit('phoneValid');
}
showSaveError(): boolean {
return (
Boolean(this.saveError()) &&
(this.saveFieldErrors().length === 0 ||
this.saveFieldErrors().some((error) => !this.known(error)))
);
}
firstNameError(): string {
return this.requiredError('firstName', 'Le prénom est obligatoire.');
}
lastNameError(): string {
return this.requiredError('lastName', 'Le nom est obligatoire.');
}
emailError(): string {
if (this.tenantForm.email().touched() && !this.tenantModel().email.trim()) {
return 'L’e-mail est obligatoire.';
}
if (this.tenantForm.email().touched() && this.tenantForm.email().invalid()) {
return 'Saisissez une adresse e-mail valide.';
}
return this.hasServerError('email', 'not_blank')
? 'L’e-mail est obligatoire.'
: this.hasServerError('email', 'email')
? 'Saisissez une adresse e-mail valide.'
: '';
}
phoneError(): string {
if (this.tenantForm.phone().touched() && !isValidPhoneNumber(this.tenantModel().phone)) {
return 'Saisissez un numéro de téléphone valide.';
}
return this.hasServerError('phoneValid', 'assert_true')
? 'Saisissez un numéro de téléphone valide.'
: '';
}
private requiredError(field: 'firstName' | 'lastName', message: string): string {
if (this.tenantForm[field]().touched() && !this.tenantModel()[field].trim()) return message;
return this.hasServerError(field, 'not_blank') ? message : '';
}
private hasClientError(): boolean {
return Boolean(
this.firstNameError() || this.lastNameError() || this.emailError() || this.phoneError(),
);
}
private firstError(): string | undefined {
return (
this.firstNameError() || this.lastNameError() || this.emailError() || this.phoneError()
);
}
private hasServerError(field: string, code: string): boolean {
return this.saveFieldErrors().some((error) => error.field === field && error.code === code);
}
private known(error: FieldValidationError): boolean {
return (
((error.field === 'firstName' || error.field === 'lastName') &&
error.code === 'not_blank') ||
(error.field === 'email' && (error.code === 'not_blank' || error.code === 'email')) ||
(error.field === 'phoneValid' && error.code === 'assert_true')
);
}
private focusFirstInvalid(): void {
const id = this.firstNameError()
? 'firstName'
: this.lastNameError()
? 'lastName'
: this.emailError()
? 'email'
: this.phoneError()
? 'phone'
: '';
this.element.nativeElement.querySelector<HTMLElement>(`#${id}`)?.focus();
}
private fillForm(tenant: Tenant | null): void {
this.tenantModel.set({
id: tenant?.id,
@@ -118,7 +118,9 @@
[tenant]="editingTenant()"
[saving]="workspace.tenantSaving(editingTenant()?.id)"
[saveError]="workspace.tenantSaveError(editingTenant()?.id)"
[saveFieldErrors]="workspace.tenantSaveFieldErrors(editingTenant()?.id)"
(cancelled)="closeForm()"
(submitted)="saveTenant($event)"
(fieldChanged)="workspace.clearTenantSaveFieldError($event, editingTenant()?.id)"
/>
}
@@ -11,6 +11,8 @@ describe('TenantsPage', () => {
useValue: {
loadTenants: () => undefined,
clearTenantSaveError: () => undefined,
clearTenantSaveFieldError: () => undefined,
tenantSaveFieldErrors: () => [],
},
},
],