Replace browser confirmations with confirmation dialog
This commit is contained in:
1 parent
a8a3d7a747
commit
5c19f244fc
17 files changed
+397
-34
No files matched your search
@@ -0,0 +1,23 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.confirmation-dialog h2 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--text);
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.confirmation-dialog p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.confirmation-dialog .confirm-action.mat-mdc-unelevated-button {
|
||||
--mdc-filled-button-container-color: var(--danger);
|
||||
--mdc-filled-button-label-text-color: var(--accent-contrast);
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="confirmation-dialog">
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content>
|
||||
<p>{{ data.message }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="cancel()">Annuler</button>
|
||||
<button mat-flat-button type="button" class="confirm-action" (click)="accept()">
|
||||
{{ data.confirmLabel }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { ConfirmationDialog, ConfirmationDialogData } from './confirmation-dialog';
|
||||
|
||||
describe('ConfirmationDialog', () => {
|
||||
const data: ConfirmationDialogData = {
|
||||
title: 'Supprimer cet appartement ?',
|
||||
message: 'Êtes-vous sûr de vouloir supprimer cet appartement ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
};
|
||||
const dialogRef = { close: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the destructive action and lets the user cancel', () => {
|
||||
const fixture = TestBed.createComponent(ConfirmationDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('[mat-dialog-title]').textContent).toContain(
|
||||
data.title,
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(data.message);
|
||||
expect(fixture.nativeElement.textContent).toContain(data.confirmLabel);
|
||||
|
||||
fixture.nativeElement.querySelector('button:first-of-type').click();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('returns true when the destructive action is selected', () => {
|
||||
const fixture = TestBed.createComponent(ConfirmationDialog);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.confirm-action').click();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
export interface ConfirmationDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirmation-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule],
|
||||
templateUrl: './confirmation-dialog.html',
|
||||
styleUrl: './confirmation-dialog.css',
|
||||
})
|
||||
export class ConfirmationDialog {
|
||||
readonly data = inject<ConfirmationDialogData>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<ConfirmationDialog>);
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
accept(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
}
|
||||
@@ -69,14 +69,29 @@
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.attachment-item {
|
||||
min-width: 0;
|
||||
}
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.attachment-row.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--text);
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
padding: 0 5px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.attachment-delete.mat-mdc-button {
|
||||
--mdc-text-button-label-text-color: var(--danger);
|
||||
flex: 0 0 auto;
|
||||
min-width: 32px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.upload-action {
|
||||
display: block;
|
||||
min-height: 40px;
|
||||
|
||||
@@ -27,24 +27,60 @@
|
||||
<strong>Pièces jointes</strong><span>{{ tenant().attachments?.length ?? 0 }}</span>
|
||||
</div>
|
||||
@for (attachment of tenant().attachments ?? []; track attachment.id) {
|
||||
<button
|
||||
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 })"
|
||||
<div
|
||||
class="attachment-item"
|
||||
[attr.aria-busy]="
|
||||
deletingAttachmentIds().has(attachment.id) ||
|
||||
downloadingAttachmentIds().has(attachment.id)
|
||||
"
|
||||
>
|
||||
@if (downloadingAttachmentIds().has(attachment.id)) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
|
||||
} @else {
|
||||
<span aria-hidden="true">↧</span>{{ attachment.fileName }}
|
||||
<div class="attachment-actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="attachment-row"
|
||||
[disabled]="
|
||||
deleting() ||
|
||||
deletingAttachmentIds().has(attachment.id) ||
|
||||
downloadingAttachmentIds().has(attachment.id)
|
||||
"
|
||||
[attr.aria-label]="'Télécharger la pièce jointe ' + attachment.fileName"
|
||||
(click)="attachmentDownloadRequested.emit({ tenantId: tenant().id!, attachment })"
|
||||
>
|
||||
@if (downloadingAttachmentIds().has(attachment.id)) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Téléchargement…
|
||||
} @else {
|
||||
<span aria-hidden="true">↧</span>{{ attachment.fileName }}
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="attachment-delete danger-action"
|
||||
[disabled]="
|
||||
deleting() ||
|
||||
deletingAttachmentIds().has(attachment.id) ||
|
||||
downloadingAttachmentIds().has(attachment.id)
|
||||
"
|
||||
[attr.aria-label]="'Retirer la pièce jointe ' + attachment.fileName"
|
||||
(click)="attachmentDeleteRequested.emit({ tenantId: tenant().id!, attachment })"
|
||||
>
|
||||
@if (deletingAttachmentIds().has(attachment.id)) {
|
||||
<mat-spinner diameter="16" aria-hidden="true" /> Suppression…
|
||||
} @else {
|
||||
<fa-icon [icon]="faXmark" aria-hidden="true" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@if (attachmentDownloadErrors()[attachment.id]) {
|
||||
<p class="attachment-error" role="alert">
|
||||
{{ attachmentDownloadErrors()[attachment.id] }}
|
||||
</p>
|
||||
}
|
||||
</button>
|
||||
@if (attachmentDownloadErrors()[attachment.id]) {
|
||||
<p class="attachment-error" role="alert">{{ attachmentDownloadErrors()[attachment.id] }}</p>
|
||||
}
|
||||
@if (attachmentDeleteErrors()[attachment.id]) {
|
||||
<p class="attachment-error" role="alert">{{ attachmentDeleteErrors()[attachment.id] }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (tenant().id) {
|
||||
@if (uploadError() || attachmentError()) {
|
||||
|
||||
@@ -78,4 +78,44 @@ describe('TenantCard', () => {
|
||||
expect(download.disabled).toBe(true);
|
||||
expect(download.textContent).toContain('Téléchargement…');
|
||||
});
|
||||
|
||||
it('shows a separate remove action for attachments', () => {
|
||||
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('deletingAttachmentIds', new Set(['attachment-1']));
|
||||
fixture.detectChanges();
|
||||
|
||||
const remove = fixture.nativeElement.querySelector('.attachment-delete');
|
||||
expect(remove.disabled).toBe(true);
|
||||
expect(remove.textContent).toContain('Suppression…');
|
||||
expect(remove.getAttribute('aria-label')).toBe('Retirer la pièce jointe id.pdf');
|
||||
});
|
||||
|
||||
it('uses a Font Awesome cross icon for the remove action', () => {
|
||||
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.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.attachment-delete fa-icon')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
@@ -11,23 +13,27 @@ export interface TenantAttachmentRequest {
|
||||
|
||||
@Component({
|
||||
selector: 'app-tenant-card',
|
||||
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
|
||||
imports: [FontAwesomeModule, MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
|
||||
templateUrl: './tenant-card.html',
|
||||
styleUrl: './tenant-card.css',
|
||||
})
|
||||
export class TenantCard {
|
||||
readonly faXmark = faXmark;
|
||||
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 deletingAttachmentIds = input<ReadonlySet<string>>(new Set());
|
||||
readonly attachmentDeleteErrors = input<Record<string, string>>({});
|
||||
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 }>();
|
||||
readonly attachmentDownloadRequested = output<TenantAttachmentRequest>();
|
||||
readonly attachmentDeleteRequested = output<TenantAttachmentRequest>();
|
||||
readonly uploadError = signal('');
|
||||
|
||||
selectFile(event: Event): void {
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"
|
||||
[deleting]="!!tenant.id && workspace.deletingTenantIds().has(tenant.id)"
|
||||
[deleteError]="tenant.id ? (workspace.tenantDeleteErrors()[tenant.id] ?? '') : ''"
|
||||
[deletingAttachmentIds]="workspace.deletingAttachmentIds()"
|
||||
[attachmentDeleteErrors]="workspace.attachmentDeleteErrors()"
|
||||
[downloadingAttachmentIds]="workspace.downloadingAttachmentIds()"
|
||||
[attachmentDownloadErrors]="workspace.attachmentDownloadErrors()"
|
||||
(editRequested)="openEditForm($event)"
|
||||
@@ -73,6 +75,9 @@
|
||||
(attachmentDownloadRequested)="
|
||||
workspace.downloadTenantAttachment($event.tenantId, $event.attachment)
|
||||
"
|
||||
(attachmentDeleteRequested)="
|
||||
workspace.deleteTenantAttachment($event.tenantId, $event.attachment)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user