feat: add loading overlay and disable actions during PDF generation for tickets
This commit is contained in:
@@ -41,9 +41,15 @@
|
||||
<app-icon-button
|
||||
variant="download"
|
||||
ariaLabel="Descargar ticket"
|
||||
[disabled]="isGeneratingPdf()"
|
||||
(clicked)="download.emit()"
|
||||
/>
|
||||
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
|
||||
<app-icon-button
|
||||
variant="share"
|
||||
ariaLabel="Compartir ticket"
|
||||
[disabled]="isGeneratingPdf()"
|
||||
(clicked)="share.emit()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -16,6 +16,7 @@ export class TicketComponent {
|
||||
readonly date = input<string | null>(null);
|
||||
readonly disabled = input(false);
|
||||
readonly expired = input(false);
|
||||
readonly isGeneratingPdf = input(false);
|
||||
readonly selected = model(false);
|
||||
|
||||
readonly viewQr = output<void>();
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<section class="tickets-page">
|
||||
<h1 class="tickets-page__title">MIS TICKETS</h1>
|
||||
|
||||
<div class="tickets-list" aria-busy="{{ isLoading() }}">
|
||||
<div class="tickets-list" [attr.aria-busy]="isLoading() || isGeneratingPdf()">
|
||||
@if (isGeneratingPdf()) {
|
||||
<div class="tickets-list__generating" role="status" aria-live="polite">
|
||||
<span class="tickets-list__generating-spinner" aria-hidden="true"></span>
|
||||
<span>Generando PDF...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTickets().length) {
|
||||
<header class="tickets-list__header">
|
||||
<label class="tickets-list__select-all">
|
||||
@@ -18,6 +25,7 @@
|
||||
<app-icon-button
|
||||
variant="download"
|
||||
ariaLabel="Descargar tickets seleccionados"
|
||||
[disabled]="isGeneratingPdf()"
|
||||
(clicked)="downloadSelected()"
|
||||
/>
|
||||
<small>Descargar</small>
|
||||
@@ -26,6 +34,7 @@
|
||||
<app-icon-button
|
||||
variant="share"
|
||||
ariaLabel="Compartir tickets seleccionados"
|
||||
[disabled]="isGeneratingPdf()"
|
||||
(clicked)="shareSelected()"
|
||||
/>
|
||||
<small>Compartir</small>
|
||||
@@ -68,6 +77,7 @@
|
||||
[ticketId]="ticket.id"
|
||||
[date]="formatDate(ticket)"
|
||||
[selected]="isSelected(ticket.id)"
|
||||
[isGeneratingPdf]="isGeneratingPdf()"
|
||||
(selectedChange)="toggleTicket(ticket.id, $event)"
|
||||
(viewQr)="viewQr(ticket)"
|
||||
(download)="downloadTicket(ticket)"
|
||||
|
||||
@@ -10,6 +10,35 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tickets-list {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tickets-list__generating {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
border-radius: 8px;
|
||||
color: #26322a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.tickets-list__generating-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #dce8e0;
|
||||
border-top-color: var(--tenant-primary, #009933);
|
||||
border-radius: 50%;
|
||||
animation: tickets-spinner 0.75s linear infinite;
|
||||
}
|
||||
|
||||
.tickets-list__header {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
@@ -170,6 +199,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tickets-spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.tickets-page__title {
|
||||
margin-top: 24px;
|
||||
|
||||
@@ -26,6 +26,122 @@ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
|
||||
});
|
||||
|
||||
describe('TicketsPage', () => {
|
||||
it('shares the generated PDF as a file without text that Windows could prioritize', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
const canShare = vi.fn().mockReturnValue(true);
|
||||
vi.stubGlobal('navigator', { canShare, share });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TicketsPage],
|
||||
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
|
||||
})
|
||||
.overrideComponent(TicketsPage, {
|
||||
set: {
|
||||
providers: [
|
||||
{
|
||||
provide: TicketService,
|
||||
useValue: {
|
||||
getTickets: () => Promise.resolve([ticket({})]),
|
||||
downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(TicketsPage);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const page = fixture.nativeElement as HTMLElement;
|
||||
page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
|
||||
|
||||
await vi.waitFor(() => expect(share).toHaveBeenCalledOnce());
|
||||
|
||||
const shareData = share.mock.calls[0][0] as ShareData;
|
||||
const [sharedFile] = shareData.files ?? [];
|
||||
|
||||
expect(Object.keys(shareData)).toEqual(['files']);
|
||||
expect(canShare).toHaveBeenCalledWith({ files: [sharedFile] });
|
||||
expect(sharedFile).toBeInstanceOf(File);
|
||||
expect(sharedFile.name).toBe('tickets_1.pdf');
|
||||
expect(sharedFile.type).toBe('application/pdf');
|
||||
expect(sharedFile.size).toBeGreaterThan(0);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows a loading overlay and disables PDF actions while the file is generated', async () => {
|
||||
let resolvePdf!: (pdf: Blob) => void;
|
||||
const downloadPdf = vi.fn(
|
||||
() =>
|
||||
new Promise<Blob>((resolve) => {
|
||||
resolvePdf = resolve;
|
||||
}),
|
||||
);
|
||||
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket');
|
||||
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
let downloadedFilename = '';
|
||||
const linkClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloadedFilename = this.download;
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TicketsPage],
|
||||
providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }],
|
||||
})
|
||||
.overrideComponent(TicketsPage, {
|
||||
set: {
|
||||
providers: [
|
||||
{
|
||||
provide: TicketService,
|
||||
useValue: {
|
||||
getTickets: () => Promise.resolve([ticket({})]),
|
||||
downloadPdf,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(TicketsPage);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const page = fixture.nativeElement as HTMLElement;
|
||||
page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(page.querySelector('.tickets-list__generating')?.textContent).toContain(
|
||||
'Generando PDF...',
|
||||
);
|
||||
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('true');
|
||||
expect(page.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.disabled).toBe(
|
||||
true,
|
||||
);
|
||||
expect(page.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.disabled).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
resolvePdf(new Blob(['pdf'], { type: 'application/pdf' }));
|
||||
await fixture.whenStable();
|
||||
await vi.waitFor(() => expect(downloadedFilename).toBe('tickets_1.pdf'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(page.querySelector('.tickets-list__generating')).toBeNull();
|
||||
expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('false');
|
||||
|
||||
createObjectUrl.mockRestore();
|
||||
revokeObjectUrl.mockRestore();
|
||||
linkClick.mockRestore();
|
||||
});
|
||||
|
||||
it('hides the active tickets header when there are no available tickets', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TicketsPage],
|
||||
|
||||
@@ -27,6 +27,7 @@ export class TicketsPage implements OnInit {
|
||||
);
|
||||
protected readonly selectedIds = signal<Set<number>>(new Set());
|
||||
protected readonly isLoading = signal(true);
|
||||
protected readonly isGeneratingPdf = signal(false);
|
||||
protected readonly allSelected = computed(
|
||||
() =>
|
||||
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
|
||||
@@ -118,42 +119,56 @@ export class TicketsPage implements OnInit {
|
||||
}
|
||||
|
||||
private async downloadTickets(tickets: TicketResponse[]): Promise<void> {
|
||||
try {
|
||||
const pdf = await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id));
|
||||
this.downloadPdf(pdf);
|
||||
} catch {
|
||||
this.toastService.danger('No se pudo generar el PDF de los tickets.');
|
||||
}
|
||||
const pdf = await this.generateTicketsPdf(tickets);
|
||||
if (pdf) this.downloadPdf(pdf, this.pdfFilename(tickets));
|
||||
}
|
||||
|
||||
private async shareTickets(tickets: TicketResponse[]): Promise<void> {
|
||||
const pdf = await this.generateTicketsPdf(tickets);
|
||||
if (!pdf) return;
|
||||
|
||||
try {
|
||||
const pdf = await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id));
|
||||
const file = new File([pdf], 'mis-tickets.pdf', { type: 'application/pdf' });
|
||||
const filename = this.pdfFilename(tickets);
|
||||
const file = new File([pdf], filename, { type: 'application/pdf' });
|
||||
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: 'Mis tickets',
|
||||
text: 'Te comparto mis tickets digitales.',
|
||||
});
|
||||
await navigator.share({ files: [file] });
|
||||
return;
|
||||
}
|
||||
|
||||
this.downloadPdf(pdf);
|
||||
this.downloadPdf(pdf, filename);
|
||||
this.toastService.success('Tu navegador no permite compartir archivos; descargamos el PDF.');
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
|
||||
this.toastService.danger('No se pudo generar el PDF de los tickets.');
|
||||
this.toastService.danger('No se pudo compartir el PDF de los tickets.');
|
||||
}
|
||||
}
|
||||
|
||||
private downloadPdf(pdf: Blob): void {
|
||||
private async generateTicketsPdf(tickets: TicketResponse[]): Promise<Blob | null> {
|
||||
if (this.isGeneratingPdf()) return null;
|
||||
|
||||
this.isGeneratingPdf.set(true);
|
||||
|
||||
try {
|
||||
return await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id));
|
||||
} catch {
|
||||
this.toastService.danger('No se pudo generar el PDF de los tickets.');
|
||||
return null;
|
||||
} finally {
|
||||
this.isGeneratingPdf.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private pdfFilename(tickets: TicketResponse[]): string {
|
||||
return `tickets_${tickets.map((ticket) => ticket.id).join('_')}.pdf`;
|
||||
}
|
||||
|
||||
private downloadPdf(pdf: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(pdf);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'mis-tickets.pdf';
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
Reference in New Issue
Block a user