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
@@ -13,6 +13,8 @@
|
||||
"@angular/material": "^22.0.0",
|
||||
"@angular/platform-browser": "^22.0.0",
|
||||
"@angular/router": "^22.0.0",
|
||||
"@fortawesome/angular-fontawesome": "^5.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
},
|
||||
@@ -245,6 +247,14 @@
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
|
||||
|
||||
"@fortawesome/angular-fontawesome": ["@fortawesome/angular-fontawesome@5.1.0", "", { "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.3.0", "tslib": "^2.8.1" }, "peerDependencies": { "@angular/core": "^22.0.0" } }, "sha512-22SF8aKzmOfeOKK2M8+hdGFw33p6S7kHWK5q/9bkqAl86SpcfIsfXVHMPhcyP0ZdiAMdeSjsuz76ye3gJbh25A=="],
|
||||
|
||||
"@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@7.3.1", "", {}, "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA=="],
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.3.1", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.3.1" } }, "sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q=="],
|
||||
|
||||
"@fortawesome/free-solid-svg-icons": ["@fortawesome/free-solid-svg-icons@7.3.1", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.3.1" } }, "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg=="],
|
||||
|
||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||
|
||||
"@harperfast/extended-iterable": ["@harperfast/extended-iterable@1.0.3", "", {}, "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw=="],
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"@angular/material": "^22.0.0",
|
||||
"@angular/platform-browser": "^22.0.0",
|
||||
"@angular/router": "^22.0.0",
|
||||
"@fortawesome/angular-fontawesome": "^5.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, computed, inject, OnInit } from '@angular/core';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { BrandMark } from './components/brand-mark';
|
||||
import { SignInPage } from './components/sign-in-page/sign-in-page';
|
||||
@@ -16,6 +17,7 @@ import { SessionService } from './services/session.service';
|
||||
RouterOutlet,
|
||||
MatProgressSpinnerModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,6 +12,8 @@ export class TenantAttachmentTransferService {
|
||||
readonly errors = signal<Record<string, string>>({});
|
||||
readonly uploadingTenantIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly uploadProgress = signal<Record<string, number | null>>({});
|
||||
readonly deletingIds = signal<ReadonlySet<string>>(new Set());
|
||||
readonly deleteErrors = signal<Record<string, string>>({});
|
||||
readonly downloadingIds = computed<ReadonlySet<string>>(() => {
|
||||
const prefix = 'attachment:';
|
||||
return new Set(
|
||||
@@ -75,6 +77,23 @@ export class TenantAttachmentTransferService {
|
||||
return this.downloads.error(this.downloadKey(attachmentId));
|
||||
}
|
||||
|
||||
delete(tenantId: string, attachmentId: string, onDeleted: () => void): void {
|
||||
if (this.deletingIds().has(attachmentId)) return;
|
||||
this.deletingIds.update((ids) => new Set(ids).add(attachmentId));
|
||||
this.deleteErrors.update((errors) => withoutRecordKey(errors, attachmentId));
|
||||
this.tenantService
|
||||
.deleteAttachment(tenantId, attachmentId)
|
||||
.pipe(finalize(() => this.deletingIds.update((ids) => withoutSetValue(ids, attachmentId))))
|
||||
.subscribe({
|
||||
next: () => onDeleted(),
|
||||
error: () =>
|
||||
this.deleteErrors.update((errors) => ({
|
||||
...errors,
|
||||
[attachmentId]: 'Impossible de supprimer cette pièce jointe.',
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private downloadKey(attachmentId: string): string {
|
||||
return `attachment:${attachmentId}`;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('TenantService', () => {
|
||||
|
||||
service.uploadAttachment('tenant-1', file).subscribe();
|
||||
service.downloadAttachment('tenant-1', 'attachment-1').subscribe();
|
||||
service.deleteAttachment('tenant-1', 'attachment-1').subscribe();
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/api/tenants/tenant-1/attachments',
|
||||
@@ -55,5 +56,6 @@ describe('TenantService', () => {
|
||||
expect(http.get).toHaveBeenCalledWith('/api/tenants/tenant-1/attachments/attachment-1', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
expect(http.delete).toHaveBeenCalledWith('/api/tenants/tenant-1/attachments/attachment-1');
|
||||
});
|
||||
});
|
||||
@@ -53,4 +53,8 @@ export class TenantService {
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
deleteAttachment(tenantId: string, attachmentId: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/tenants/${tenantId}/attachments/${attachmentId}`);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { HttpEvent, HttpEventType, HttpResponse } from '@angular/common/http';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
@@ -6,6 +7,7 @@ import { ApartmentService } from './apartment.service';
|
||||
import { LeaseService } from './lease.service';
|
||||
import { TenantService } from './tenant.service';
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
import { ConfirmationDialog } from '../components/confirmation-dialog/confirmation-dialog';
|
||||
|
||||
describe('WorkspaceService', () => {
|
||||
const apartment = {
|
||||
@@ -48,6 +50,7 @@ describe('WorkspaceService', () => {
|
||||
deleteTenant: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
downloadAttachment: vi.fn(),
|
||||
deleteAttachment: vi.fn(),
|
||||
};
|
||||
const leases = {
|
||||
getLeases: vi.fn(),
|
||||
@@ -55,6 +58,9 @@ describe('WorkspaceService', () => {
|
||||
deleteLease: vi.fn(),
|
||||
downloadPdf: vi.fn(),
|
||||
};
|
||||
const dialog = {
|
||||
open: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -68,14 +74,12 @@ describe('WorkspaceService', () => {
|
||||
tenants.deleteTenant.mockReturnValue(of(undefined));
|
||||
tenants.uploadAttachment.mockReturnValue(of({ id: 'attachment-1', fileName: 'id.pdf' }));
|
||||
tenants.downloadAttachment.mockReturnValue(of(new Blob(['identity'])));
|
||||
tenants.deleteAttachment.mockReturnValue(of(undefined));
|
||||
leases.getLeases.mockReturnValue(of([lease]));
|
||||
leases.createLease.mockReturnValue(of(lease));
|
||||
leases.deleteLease.mockReturnValue(of(undefined));
|
||||
leases.downloadPdf.mockReturnValue(of(new Blob(['pdf'])));
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
);
|
||||
dialog.open.mockImplementation(() => ({ afterClosed: () => of(true) }));
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test'), revokeObjectURL: vi.fn() });
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
TestBed.configureTestingModule({
|
||||
@@ -84,6 +88,7 @@ describe('WorkspaceService', () => {
|
||||
{ provide: ApartmentService, useValue: apartments },
|
||||
{ provide: TenantService, useValue: tenants },
|
||||
{ provide: LeaseService, useValue: leases },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -236,7 +241,12 @@ describe('WorkspaceService', () => {
|
||||
});
|
||||
|
||||
it('reports attachment upload progress and keeps the confirmed attachment', () => {
|
||||
const attachment = { id: 'attachment-1', fileName: 'id.pdf' };
|
||||
const attachment = {
|
||||
id: 'attachment-1',
|
||||
fileName: 'id.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 42,
|
||||
};
|
||||
const uploadEvents = new Subject<HttpEvent<typeof attachment>>();
|
||||
const refreshResponse = new Subject<(typeof tenant)[]>();
|
||||
tenants.uploadAttachment.mockReturnValueOnce(uploadEvents);
|
||||
@@ -277,6 +287,32 @@ describe('WorkspaceService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a confirmed attachment locally before reconciliation finishes', () => {
|
||||
const attachment = {
|
||||
id: 'attachment-1',
|
||||
fileName: 'id.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 42,
|
||||
};
|
||||
const deleteResponse = new Subject<void>();
|
||||
const refreshResponse = new Subject<(typeof tenant)[]>();
|
||||
const tenantWithAttachment = { ...tenant, attachments: [attachment] };
|
||||
tenants.deleteAttachment.mockReturnValueOnce(deleteResponse);
|
||||
tenants.getTenants.mockReturnValueOnce(refreshResponse);
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
service.tenants.set([tenantWithAttachment]);
|
||||
|
||||
service.deleteTenantAttachment(tenant.id, attachment);
|
||||
expect(service.deletingAttachmentIds().has(attachment.id)).toBe(true);
|
||||
expect(service.tenants()[0].attachments).toEqual([attachment]);
|
||||
|
||||
deleteResponse.next();
|
||||
deleteResponse.complete();
|
||||
expect(service.tenants()[0].attachments).toEqual([]);
|
||||
expect(service.deletingAttachmentIds().has(attachment.id)).toBe(false);
|
||||
expect(service.tenantLoadState().loading).toBe(true);
|
||||
});
|
||||
|
||||
it('loads, creates, updates, and deletes every workspace resource', () => {
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
service.loadApartments();
|
||||
@@ -299,11 +335,33 @@ describe('WorkspaceService', () => {
|
||||
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
|
||||
});
|
||||
|
||||
it('only deletes confirmed resources and handles attachment downloads', () => {
|
||||
it('opens a confirmation pop-in before deleting a resource', () => {
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
const confirmation = vi.mocked(confirm);
|
||||
confirmation.mockReturnValueOnce(false);
|
||||
const closed = new Subject<boolean>();
|
||||
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => closed });
|
||||
service.deleteApartment(apartment.id);
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
ConfirmationDialog,
|
||||
expect.objectContaining({
|
||||
role: 'alertdialog',
|
||||
data: {
|
||||
title: 'Supprimer cet appartement ?',
|
||||
message: 'Êtes-vous sûr de vouloir supprimer cet appartement ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(apartments.deleteApartment).not.toHaveBeenCalled();
|
||||
|
||||
closed.next(false);
|
||||
closed.complete();
|
||||
expect(apartments.deleteApartment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only deletes resources confirmed in the pop-in and handles attachment downloads', () => {
|
||||
const service = TestBed.inject(WorkspaceService);
|
||||
service.deleteTenant(tenant.id);
|
||||
service.deleteLease(lease.id);
|
||||
service.uploadTenantAttachment('tenant-1', new File(['id'], 'id.pdf'));
|
||||
@@ -311,7 +369,6 @@ describe('WorkspaceService', () => {
|
||||
service.downloadLeasePdf(lease);
|
||||
service.downloadLeasePdf({ ...lease, id: undefined });
|
||||
|
||||
expect(apartments.deleteApartment).not.toHaveBeenCalled();
|
||||
expect(tenants.deleteTenant).toHaveBeenCalledWith('tenant-1');
|
||||
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
ConfirmationDialogData,
|
||||
} from '../components/confirmation-dialog/confirmation-dialog';
|
||||
import { Apartment, ApartmentService } from './apartment.service';
|
||||
import { FileDownloadService } from './file-download.service';
|
||||
import { Lease, LeaseService } from './lease.service';
|
||||
import { Tenant, TenantService } from './tenant.service';
|
||||
import { Tenant, TenantAttachment, TenantService } from './tenant.service';
|
||||
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
|
||||
import { WorkspaceResource } from './workspace-resource';
|
||||
|
||||
@@ -10,6 +15,7 @@ const newResourceKey = 'new';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WorkspaceService {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly apartmentService = inject(ApartmentService);
|
||||
private readonly tenantService = inject(TenantService);
|
||||
private readonly leaseService = inject(LeaseService);
|
||||
@@ -51,6 +57,8 @@ export class WorkspaceService {
|
||||
readonly tenantAttachmentErrors = this.attachmentTransfers.errors;
|
||||
readonly uploadingTenantIds = this.attachmentTransfers.uploadingTenantIds;
|
||||
readonly tenantUploadProgress = this.attachmentTransfers.uploadProgress;
|
||||
readonly deletingAttachmentIds = this.attachmentTransfers.deletingIds;
|
||||
readonly attachmentDeleteErrors = this.attachmentTransfers.deleteErrors;
|
||||
readonly downloadingAttachmentIds = this.attachmentTransfers.downloadingIds;
|
||||
readonly attachmentDownloadErrors = this.attachmentTransfers.downloadErrors;
|
||||
|
||||
@@ -81,8 +89,14 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
deleteApartment(id: string): void {
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?'))
|
||||
this.apartmentResource.delete(id, this.apartmentService.deleteApartment(id));
|
||||
this.requestConfirmation(
|
||||
{
|
||||
title: 'Supprimer cet appartement ?',
|
||||
message: 'Êtes-vous sûr de vouloir supprimer cet appartement ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
},
|
||||
() => this.apartmentResource.delete(id, this.apartmentService.deleteApartment(id)),
|
||||
);
|
||||
}
|
||||
|
||||
loadTenants(): void {
|
||||
@@ -110,8 +124,14 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
deleteTenant(id: string): void {
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?'))
|
||||
this.tenantResource.delete(id, this.tenantService.deleteTenant(id));
|
||||
this.requestConfirmation(
|
||||
{
|
||||
title: 'Supprimer ce locataire ?',
|
||||
message: 'Êtes-vous sûr de vouloir supprimer ce locataire ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
},
|
||||
() => this.tenantResource.delete(id, this.tenantService.deleteTenant(id)),
|
||||
);
|
||||
}
|
||||
|
||||
uploadTenantAttachment(tenantId: string, file: File): void {
|
||||
@@ -139,6 +159,24 @@ export class WorkspaceService {
|
||||
return this.attachmentTransfers.downloadError(attachmentId);
|
||||
}
|
||||
|
||||
deleteTenantAttachment(tenantId: string, attachment: TenantAttachment): void {
|
||||
this.requestConfirmation(
|
||||
{
|
||||
title: 'Retirer cette pièce jointe ?',
|
||||
message: `Êtes-vous sûr de vouloir retirer « ${attachment.fileName} » ?`,
|
||||
confirmLabel: 'Retirer',
|
||||
},
|
||||
() =>
|
||||
this.attachmentTransfers.delete(tenantId, attachment.id, () => {
|
||||
this.tenantResource.update(tenantId, (tenant) => ({
|
||||
...tenant,
|
||||
attachments: (tenant.attachments ?? []).filter((item) => item.id !== attachment.id),
|
||||
}));
|
||||
this.tenantResource.load();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
loadLeases(): void {
|
||||
this.leaseResource.load();
|
||||
}
|
||||
@@ -160,8 +198,14 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
deleteLease(id: string): void {
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer ce bail ?'))
|
||||
this.leaseResource.delete(id, this.leaseService.deleteLease(id));
|
||||
this.requestConfirmation(
|
||||
{
|
||||
title: 'Supprimer ce bail ?',
|
||||
message: 'Êtes-vous sûr de vouloir supprimer ce bail ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
},
|
||||
() => this.leaseResource.delete(id, this.leaseService.deleteLease(id)),
|
||||
);
|
||||
}
|
||||
|
||||
downloadLeasePdf(lease: Lease): void {
|
||||
@@ -189,4 +233,18 @@ export class WorkspaceService {
|
||||
private leaseDownloadKey(leaseId: string): string {
|
||||
return `lease:${leaseId}`;
|
||||
}
|
||||
|
||||
private requestConfirmation(data: ConfirmationDialogData, onConfirmed: () => void): void {
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data,
|
||||
role: 'alertdialog',
|
||||
width: 'min(420px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
if (confirmed) onConfirmed();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user