feat: add comprehensive loading feedback (#18)
This commit is contained in:
1 parent
45de0cebcc
commit
ec3319494b
47 files changed
+1753
-406
No files matched your search
@@ -97,6 +97,19 @@
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.upload-progress {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.attachment-row mat-spinner,
|
||||
.card-actions mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.attachment-error {
|
||||
margin: 8px 0 0;
|
||||
color: var(--danger);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<article class="tenant-card">
|
||||
<article class="tenant-card" [attr.aria-busy]="deleting() || uploading()">
|
||||
<div class="tenant-identity">
|
||||
<span class="tenant-avatar"
|
||||
>{{ tenant().firstName.charAt(0) }}{{ tenant().lastName.charAt(0) }}</span
|
||||
@@ -31,11 +31,20 @@
|
||||
mat-button
|
||||
type="button"
|
||||
class="attachment-row"
|
||||
[attr.aria-busy]="downloadingAttachmentIds().has(attachment.id)"
|
||||
[disabled]="deleting() || downloadingAttachmentIds().has(attachment.id)"
|
||||
[attr.aria-label]="'Télécharger la pièce jointe ' + attachment.fileName"
|
||||
(click)="attachmentDownloadRequested.emit({ tenantId: tenant().id!, attachment })"
|
||||
>
|
||||
<span aria-hidden="true">↧</span>{{ attachment.fileName }}
|
||||
@if (downloadingAttachmentIds().has(attachment.id)) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
|
||||
} @else {
|
||||
<span aria-hidden="true">↧</span>{{ attachment.fileName }}
|
||||
}
|
||||
</button>
|
||||
@if (attachmentDownloadErrors()[attachment.id]) {
|
||||
<p class="attachment-error" role="alert">{{ attachmentDownloadErrors()[attachment.id] }}</p>
|
||||
}
|
||||
}
|
||||
@if (tenant().id) {
|
||||
@if (uploadError() || attachmentError()) {
|
||||
@@ -44,24 +53,44 @@
|
||||
<label class="upload-action"
|
||||
><input
|
||||
type="file"
|
||||
[disabled]="uploading() || deleting()"
|
||||
[attr.aria-label]="
|
||||
'Ajouter une pièce jointe pour ' + tenant().firstName + ' ' + tenant().lastName
|
||||
"
|
||||
(change)="selectFile($event)"
|
||||
/>+ Ajouter une pièce jointe <small>· 250 Mo maximum</small></label
|
||||
>
|
||||
@if (uploading()) {
|
||||
<div class="upload-progress" role="status" aria-live="polite">
|
||||
<span>Envoi{{ uploadProgress() === null ? '…' : ' : ' + uploadProgress() + ' %' }}</span>
|
||||
<mat-progress-bar
|
||||
[mode]="uploadProgress() === null ? 'indeterminate' : 'determinate'"
|
||||
[value]="uploadProgress() ?? 0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button mat-button type="button" (click)="editRequested.emit(tenant())">Modifier</button>
|
||||
@if (deleteError()) {
|
||||
<p class="attachment-error" role="alert">{{ deleteError() }}</p>
|
||||
}
|
||||
<div class="card-actions" aria-live="polite">
|
||||
<button mat-button type="button" [disabled]="deleting()" (click)="editRequested.emit(tenant())">
|
||||
Modifier
|
||||
</button>
|
||||
@if (tenant().id) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-action"
|
||||
[disabled]="deleting()"
|
||||
(click)="deleteRequested.emit(tenant().id!)"
|
||||
>
|
||||
Supprimer
|
||||
@if (deleting()) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
|
||||
} @else {
|
||||
Supprimer
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -21,4 +21,44 @@ describe('TenantCard', () => {
|
||||
|
||||
expect(fixture.componentInstance.uploadError()).toContain('250 Mo');
|
||||
});
|
||||
|
||||
it('shows measurable progress and disables only its upload control', () => {
|
||||
const fixture = TestBed.createComponent(TenantCard);
|
||||
fixture.componentRef.setInput('tenant', {
|
||||
id: 'tenant-1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
});
|
||||
fixture.componentRef.setInput('uploading', true);
|
||||
fixture.componentRef.setInput('uploadProgress', 50);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('input[type=file]').disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('mat-progress-bar')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Envoi : 50 %');
|
||||
});
|
||||
|
||||
it('shows progress only on the selected attachment download', () => {
|
||||
const fixture = TestBed.createComponent(TenantCard);
|
||||
fixture.componentRef.setInput('tenant', {
|
||||
id: 'tenant-1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Martin',
|
||||
email: 'camille@example.com',
|
||||
phone: '',
|
||||
address: '',
|
||||
attachments: [
|
||||
{ id: 'attachment-1', fileName: 'id.pdf', contentType: 'application/pdf', size: 42 },
|
||||
],
|
||||
});
|
||||
fixture.componentRef.setInput('downloadingAttachmentIds', new Set(['attachment-1']));
|
||||
fixture.detectChanges();
|
||||
|
||||
const download = fixture.nativeElement.querySelector('.attachment-row');
|
||||
expect(download.disabled).toBe(true);
|
||||
expect(download.textContent).toContain('Téléchargement…');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Tenant, TenantAttachment } from '../../services/tenant.service';
|
||||
|
||||
export interface TenantAttachmentRequest {
|
||||
@@ -9,13 +11,19 @@ export interface TenantAttachmentRequest {
|
||||
|
||||
@Component({
|
||||
selector: 'app-tenant-card',
|
||||
imports: [MatButtonModule],
|
||||
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
|
||||
templateUrl: './tenant-card.html',
|
||||
styleUrl: './tenant-card.css',
|
||||
})
|
||||
export class TenantCard {
|
||||
readonly tenant = input.required<Tenant>();
|
||||
readonly attachmentError = input('');
|
||||
readonly uploading = input(false);
|
||||
readonly uploadProgress = input<number | null>(null);
|
||||
readonly deleting = input(false);
|
||||
readonly deleteError = input('');
|
||||
readonly downloadingAttachmentIds = input<ReadonlySet<string>>(new Set());
|
||||
readonly attachmentDownloadErrors = input<Record<string, string>>({});
|
||||
readonly editRequested = output<Tenant>();
|
||||
readonly deleteRequested = output<string>();
|
||||
readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>();
|
||||
@@ -24,7 +32,7 @@ export class TenantCard {
|
||||
|
||||
selectFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.item(0);
|
||||
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.');
|
||||
|
||||
Reference in new issue
Block a user