diff --git a/frontend/src/app/components/tenant-card/tenant-card.spec.ts b/frontend/src/app/components/tenant-card/tenant-card.spec.ts
index d8de798..bd22490 100644
--- a/frontend/src/app/components/tenant-card/tenant-card.spec.ts
+++ b/frontend/src/app/components/tenant-card/tenant-card.spec.ts
@@ -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', () => {
diff --git a/frontend/src/app/components/tenant-card/tenant-card.ts b/frontend/src/app/components/tenant-card/tenant-card.ts
index 37db78d..7674869 100644
--- a/frontend/src/app/components/tenant-card/tenant-card.ts
+++ b/frontend/src/app/components/tenant-card/tenant-card.ts
@@ -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 });
}
diff --git a/frontend/src/app/components/tenant-form/tenant-form.css b/frontend/src/app/components/tenant-form/tenant-form.css
index 16fed30..ec531c6 100644
--- a/frontend/src/app/components/tenant-form/tenant-form.css
+++ b/frontend/src/app/components/tenant-form/tenant-form.css
@@ -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;
diff --git a/frontend/src/app/components/tenant-form/tenant-form.html b/frontend/src/app/components/tenant-form/tenant-form.html
index 3d14d70..b7ffd84 100644
--- a/frontend/src/app/components/tenant-form/tenant-form.html
+++ b/frontend/src/app/components/tenant-form/tenant-form.html
@@ -21,94 +21,98 @@
(ngSubmit)="save()"
[attr.aria-busy]="saving()"
>
- @if (saveError()) {
+ @if (showSaveError()) {
{{ saveError() }}
}
+ @if (validationError()) {
+
{{ validationError() }}
+ }
{{ saving() ? 'Enregistrement en cours.' : '' }}
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 b621565..1d59f69 100644
--- a/frontend/src/app/components/tenant-form/tenant-form.spec.ts
+++ b/frontend/src/app/components/tenant-form/tenant-form.spec.ts
@@ -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');
});
});
diff --git a/frontend/src/app/components/tenant-form/tenant-form.ts b/frontend/src/app/components/tenant-form/tenant-form.ts
index 367d9d2..4078e02 100644
--- a/frontend/src/app/components/tenant-form/tenant-form.ts
+++ b/frontend/src/app/components/tenant-form/tenant-form.ts
@@ -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);
readonly tenant = input(null);
readonly saving = input(false);
readonly saveError = input('');
+ readonly saveFieldErrors = input([]);
readonly cancelled = output();
readonly submitted = output();
+ readonly fieldChanged = output();
+ private previousServerErrorSignature = '';
readonly tenantModel = signal({
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(`#${id}`)?.focus();
+ }
+
private fillForm(tenant: Tenant | null): void {
this.tenantModel.set({
id: tenant?.id,
diff --git a/frontend/src/app/components/tenants-page/tenants-page.html b/frontend/src/app/components/tenants-page/tenants-page.html
index 2f8d19c..9786aa8 100644
--- a/frontend/src/app/components/tenants-page/tenants-page.html
+++ b/frontend/src/app/components/tenants-page/tenants-page.html
@@ -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)"
/>
}
diff --git a/frontend/src/app/components/tenants-page/tenants-page.spec.ts b/frontend/src/app/components/tenants-page/tenants-page.spec.ts
index ff72e59..34b1569 100644
--- a/frontend/src/app/components/tenants-page/tenants-page.spec.ts
+++ b/frontend/src/app/components/tenants-page/tenants-page.spec.ts
@@ -11,6 +11,8 @@ describe('TenantsPage', () => {
useValue: {
loadTenants: () => undefined,
clearTenantSaveError: () => undefined,
+ clearTenantSaveFieldError: () => undefined,
+ tenantSaveFieldErrors: () => [],
},
},
],
diff --git a/frontend/src/app/services/tenant-attachment-transfer.service.spec.ts b/frontend/src/app/services/tenant-attachment-transfer.service.spec.ts
new file mode 100644
index 0000000..569c5fc
--- /dev/null
+++ b/frontend/src/app/services/tenant-attachment-transfer.service.spec.ts
@@ -0,0 +1,87 @@
+import { HttpEvent, HttpEventType, HttpResponse } from '@angular/common/http';
+import { TestBed } from '@angular/core/testing';
+import { signal } from '@angular/core';
+import { Observable, of, Subject, throwError } from 'rxjs';
+import { FileDownloadService } from './file-download.service';
+import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
+import { TenantAttachment, TenantService } from './tenant.service';
+
+describe('TenantAttachmentTransferService', () => {
+ let uploads: Subject>;
+ const tenants = {
+ uploadAttachment:
+ vi.fn<(tenantId: string, file: File) => Observable>>(),
+ deleteAttachment: vi.fn<(tenantId: string, attachmentId: string) => Observable>(),
+ downloadAttachment: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ uploads = new Subject>();
+ tenants.uploadAttachment.mockImplementation(() => uploads);
+ tenants.deleteAttachment.mockImplementation(() => of(undefined));
+ TestBed.configureTestingModule({
+ providers: [
+ TenantAttachmentTransferService,
+ { provide: TenantService, useValue: tenants },
+ {
+ provide: FileDownloadService,
+ useValue: {
+ pendingKeys: signal(new Set()),
+ errors: signal({}),
+ isPending: () => false,
+ download: () => undefined,
+ },
+ },
+ ],
+ });
+ });
+
+ it('keeps upload feedback local to the selected tenant', () => {
+ const service = TestBed.inject(TenantAttachmentTransferService);
+ const uploaded = vi.fn();
+
+ service.upload('tenant-1', new File(['id'], 'id.pdf'), uploaded);
+ expect(service.uploadingTenantIds().has('tenant-1')).toBe(true);
+ uploads.next({ type: HttpEventType.UploadProgress, loaded: 1, total: 2 });
+ expect(service.uploadProgress()['tenant-1']).toBe(50);
+ uploads.next(
+ new HttpResponse({
+ body: {
+ id: 'attachment-1',
+ fileName: 'id.pdf',
+ contentType: 'application/pdf',
+ size: 2,
+ },
+ }),
+ );
+ expect(uploaded).toHaveBeenCalledOnce();
+ uploads.complete();
+ expect(service.uploadingTenantIds().has('tenant-1')).toBe(false);
+ });
+
+ it('keeps a failed attachment deletion actionable on that attachment', () => {
+ tenants.deleteAttachment.mockReturnValue(
+ throwError(() => ({
+ status: 409,
+ error: { message: 'La pièce jointe est indisponible.' },
+ })),
+ );
+ const service = TestBed.inject(TenantAttachmentTransferService);
+
+ service.delete('tenant-1', 'attachment-1', vi.fn());
+
+ expect(service.deletingIds().has('attachment-1')).toBe(false);
+ expect(service.deleteErrors()['attachment-1']).toBe('La pièce jointe est indisponible.');
+ });
+
+ it('keeps a failed upload actionable on the selected tenant', () => {
+ tenants.uploadAttachment.mockReturnValue(throwError(() => ({ status: 413 })));
+ const service = TestBed.inject(TenantAttachmentTransferService);
+
+ service.upload('tenant-1', new File(['id'], 'id.pdf'), vi.fn());
+
+ expect(service.uploadingTenantIds().has('tenant-1')).toBe(false);
+ expect(service.errors()['tenant-1']).toContain('taille maximale');
+ });
+});
diff --git a/frontend/src/app/services/tenant-attachment-transfer.service.ts b/frontend/src/app/services/tenant-attachment-transfer.service.ts
index 56695f6..14554d9 100644
--- a/frontend/src/app/services/tenant-attachment-transfer.service.ts
+++ b/frontend/src/app/services/tenant-attachment-transfer.service.ts
@@ -4,7 +4,7 @@ import { finalize } from 'rxjs';
import { FileDownloadService } from './file-download.service';
import { TenantAttachment, TenantService } from './tenant.service';
import { withoutRecordKey, withoutSetValue } from './immutable-state';
-import { httpErrorMessage } from './http-error-message';
+import { saveFailure } from './http-error-message';
@Injectable({ providedIn: 'root' })
export class TenantAttachmentTransferService {
@@ -60,10 +60,10 @@ export class TenantAttachmentTransferService {
}
},
error: (error: unknown) => {
- const message = httpErrorMessage(
+ const message = saveFailure(
error,
- 'Impossible d’envoyer cette pièce jointe.',
- );
+ 'Impossible d’envoyer cette pièce jointe. Vérifiez le fichier et sa taille maximale.',
+ ).message;
this.errors.update((errors) => ({ ...errors, [tenantId]: message }));
},
});
@@ -99,10 +99,13 @@ export class TenantAttachmentTransferService {
)
.subscribe({
next: () => onDeleted(),
- error: () =>
+ error: (error: unknown) =>
this.deleteErrors.update((errors) => ({
...errors,
- [attachmentId]: 'Impossible de supprimer cette pièce jointe.',
+ [attachmentId]: saveFailure(
+ error,
+ 'Impossible de supprimer cette pièce jointe. Réessayez.',
+ ).message,
})),
});
}
diff --git a/frontend/src/app/services/workspace.service.ts b/frontend/src/app/services/workspace.service.ts
index 9471382..1e35878 100644
--- a/frontend/src/app/services/workspace.service.ts
+++ b/frontend/src/app/services/workspace.service.ts
@@ -161,10 +161,18 @@ export class WorkspaceService {
return this.tenantResource.saveErrors()[this.resourceKey(id)] ?? '';
}
+ tenantSaveFieldErrors(id?: string): FieldValidationError[] {
+ return this.tenantResource.saveFieldErrors()[this.resourceKey(id)] ?? [];
+ }
+
clearTenantSaveError(id?: string): void {
this.tenantResource.clearSaveError(this.resourceKey(id));
}
+ clearTenantSaveFieldError(field: string, id?: string): void {
+ this.tenantResource.clearSaveFieldError(this.resourceKey(id), field);
+ }
+
deleteTenant(id: string): void {
this.requestConfirmation(
{