feat: add loading overlay and disable actions during PDF generation for tickets
This commit is contained in:
@@ -41,9 +41,15 @@
|
|||||||
<app-icon-button
|
<app-icon-button
|
||||||
variant="download"
|
variant="download"
|
||||||
ariaLabel="Descargar ticket"
|
ariaLabel="Descargar ticket"
|
||||||
|
[disabled]="isGeneratingPdf()"
|
||||||
(clicked)="download.emit()"
|
(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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export class TicketComponent {
|
|||||||
readonly date = input<string | null>(null);
|
readonly date = input<string | null>(null);
|
||||||
readonly disabled = input(false);
|
readonly disabled = input(false);
|
||||||
readonly expired = input(false);
|
readonly expired = input(false);
|
||||||
|
readonly isGeneratingPdf = input(false);
|
||||||
readonly selected = model(false);
|
readonly selected = model(false);
|
||||||
|
|
||||||
readonly viewQr = output<void>();
|
readonly viewQr = output<void>();
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<section class="tickets-page">
|
<section class="tickets-page">
|
||||||
<h1 class="tickets-page__title">MIS TICKETS</h1>
|
<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) {
|
@if (activeTickets().length) {
|
||||||
<header class="tickets-list__header">
|
<header class="tickets-list__header">
|
||||||
<label class="tickets-list__select-all">
|
<label class="tickets-list__select-all">
|
||||||
@@ -18,6 +25,7 @@
|
|||||||
<app-icon-button
|
<app-icon-button
|
||||||
variant="download"
|
variant="download"
|
||||||
ariaLabel="Descargar tickets seleccionados"
|
ariaLabel="Descargar tickets seleccionados"
|
||||||
|
[disabled]="isGeneratingPdf()"
|
||||||
(clicked)="downloadSelected()"
|
(clicked)="downloadSelected()"
|
||||||
/>
|
/>
|
||||||
<small>Descargar</small>
|
<small>Descargar</small>
|
||||||
@@ -26,6 +34,7 @@
|
|||||||
<app-icon-button
|
<app-icon-button
|
||||||
variant="share"
|
variant="share"
|
||||||
ariaLabel="Compartir tickets seleccionados"
|
ariaLabel="Compartir tickets seleccionados"
|
||||||
|
[disabled]="isGeneratingPdf()"
|
||||||
(clicked)="shareSelected()"
|
(clicked)="shareSelected()"
|
||||||
/>
|
/>
|
||||||
<small>Compartir</small>
|
<small>Compartir</small>
|
||||||
@@ -68,6 +77,7 @@
|
|||||||
[ticketId]="ticket.id"
|
[ticketId]="ticket.id"
|
||||||
[date]="formatDate(ticket)"
|
[date]="formatDate(ticket)"
|
||||||
[selected]="isSelected(ticket.id)"
|
[selected]="isSelected(ticket.id)"
|
||||||
|
[isGeneratingPdf]="isGeneratingPdf()"
|
||||||
(selectedChange)="toggleTicket(ticket.id, $event)"
|
(selectedChange)="toggleTicket(ticket.id, $event)"
|
||||||
(viewQr)="viewQr(ticket)"
|
(viewQr)="viewQr(ticket)"
|
||||||
(download)="downloadTicket(ticket)"
|
(download)="downloadTicket(ticket)"
|
||||||
|
|||||||
@@ -10,6 +10,35 @@
|
|||||||
font-weight: 700;
|
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 {
|
.tickets-list__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
@@ -170,6 +199,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes tickets-spinner {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.tickets-page__title {
|
.tickets-page__title {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
|
|||||||
@@ -26,6 +26,122 @@ const ticket = (overrides: Partial<TicketResponse>): TicketResponse => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('TicketsPage', () => {
|
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 () => {
|
it('hides the active tickets header when there are no available tickets', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [TicketsPage],
|
imports: [TicketsPage],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export class TicketsPage implements OnInit {
|
|||||||
);
|
);
|
||||||
protected readonly selectedIds = signal<Set<number>>(new Set());
|
protected readonly selectedIds = signal<Set<number>>(new Set());
|
||||||
protected readonly isLoading = signal(true);
|
protected readonly isLoading = signal(true);
|
||||||
|
protected readonly isGeneratingPdf = signal(false);
|
||||||
protected readonly allSelected = computed(
|
protected readonly allSelected = computed(
|
||||||
() =>
|
() =>
|
||||||
this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length,
|
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> {
|
private async downloadTickets(tickets: TicketResponse[]): Promise<void> {
|
||||||
try {
|
const pdf = await this.generateTicketsPdf(tickets);
|
||||||
const pdf = await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id));
|
if (pdf) this.downloadPdf(pdf, this.pdfFilename(tickets));
|
||||||
this.downloadPdf(pdf);
|
|
||||||
} catch {
|
|
||||||
this.toastService.danger('No se pudo generar el PDF de los tickets.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async shareTickets(tickets: TicketResponse[]): Promise<void> {
|
private async shareTickets(tickets: TicketResponse[]): Promise<void> {
|
||||||
|
const pdf = await this.generateTicketsPdf(tickets);
|
||||||
|
if (!pdf) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pdf = await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id));
|
const filename = this.pdfFilename(tickets);
|
||||||
const file = new File([pdf], 'mis-tickets.pdf', { type: 'application/pdf' });
|
const file = new File([pdf], filename, { type: 'application/pdf' });
|
||||||
|
|
||||||
if (navigator.canShare?.({ files: [file] })) {
|
if (navigator.canShare?.({ files: [file] })) {
|
||||||
await navigator.share({
|
await navigator.share({ files: [file] });
|
||||||
files: [file],
|
|
||||||
title: 'Mis tickets',
|
|
||||||
text: 'Te comparto mis tickets digitales.',
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.downloadPdf(pdf);
|
this.downloadPdf(pdf, filename);
|
||||||
this.toastService.success('Tu navegador no permite compartir archivos; descargamos el PDF.');
|
this.toastService.success('Tu navegador no permite compartir archivos; descargamos el PDF.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
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 url = URL.createObjectURL(pdf);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = 'mis-tickets.pdf';
|
link.download = filename;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
|
|||||||
Reference in New Issue
Block a user