Align tenant and attachment validation (#48)
This commit is contained in:
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: () => [],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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<HttpEvent<TenantAttachment>>;
|
||||
const tenants = {
|
||||
uploadAttachment:
|
||||
vi.fn<(tenantId: string, file: File) => Observable<HttpEvent<TenantAttachment>>>(),
|
||||
deleteAttachment: vi.fn<(tenantId: string, attachmentId: string) => Observable<void>>(),
|
||||
downloadAttachment: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
uploads = new Subject<HttpEvent<TenantAttachment>>();
|
||||
tenants.uploadAttachment.mockImplementation(() => uploads);
|
||||
tenants.deleteAttachment.mockImplementation(() => of(undefined));
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TenantAttachmentTransferService,
|
||||
{ provide: TenantService, useValue: tenants },
|
||||
{
|
||||
provide: FileDownloadService,
|
||||
useValue: {
|
||||
pendingKeys: signal(new Set<string>()),
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user