From 3e40dacd72f7fb6d28759c433b68fca75c66d7d7 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Mon, 10 Jun 2024 23:27:00 -0300 Subject: [PATCH] feat: disable button while file is being downloaded Close #126 --- .../ConcurrentStreamResourceWriter.java | 80 +++++++---- .../addons/gridexporter/GridExporter.java | 130 +++++++++++++----- .../GridExporterBigDatasetDemo.java | 3 + .../test/ConcurrentExportTests.java | 12 ++ 4 files changed, 169 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentStreamResourceWriter.java b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentStreamResourceWriter.java index 88bcc69..4bfd1ff 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentStreamResourceWriter.java +++ b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/ConcurrentStreamResourceWriter.java @@ -157,6 +157,36 @@ public float getCost(VaadinSession session) { */ protected abstract void onTimeout(); + /** + * Callback method that is invoked when a download is accepted. + *

+ * This method is called at the start of the download process, right after the + * {@link #accept(OutputStream, VaadinSession) accept} method is invoked and it has been + * determined that the download can proceed. Subclasses should implement this method to perform + * any necessary actions before the download begins, such as initializing resources, logging, or + * updating the UI to reflect the start of the download. + *

+ * Note that this method is called before any semaphore permits are acquired, so it is executed + * regardless of whether the semaphore is enabled or not. + *

+ */ + protected abstract void onAccept(); + + /** + * Callback method that is invoked when a download finishes. + *

+ * This method is called at the end of the download process, right before the + * {@link #accept(OutputStream, VaadinSession) accept} method returns, regardless of whether the + * download was successful, timed out, or encountered an error. Subclasses should implement this + * method to perform any necessary actions after the download completes, such as releasing + * resources, logging, or updating the UI to reflect the completion of the download. + *

+ * Note that this method is always called, even if an exception is thrown during the download + * process, ensuring that any necessary cleanup can be performed. + *

+ */ + protected abstract void onFinish(); + /** * Handles {@code stream} (writes data to it) using {@code session} as a context. *

@@ -175,33 +205,37 @@ public float getCost(VaadinSession session) { */ @Override public final void accept(OutputStream stream, VaadinSession session) throws IOException { + onAccept(); + try { + if (!enabled) { + delegate.accept(stream, session); + } else { + + try { + + int permits; + float cost = getCost(session); + synchronized (semaphore) { + permits = costToPermits(cost, semaphore.maxPermits); + } - if (!enabled) { - delegate.accept(stream, session); - } else { - - try { - - int permits; - float cost = getCost(session); - synchronized (semaphore) { - permits = costToPermits(cost, semaphore.maxPermits); - } - - if (semaphore.tryAcquire(permits, getTimeout(), TimeUnit.NANOSECONDS)) { - try { - delegate.accept(stream, session); - } finally { - semaphore.release(permits); + if (semaphore.tryAcquire(permits, getTimeout(), TimeUnit.NANOSECONDS)) { + try { + delegate.accept(stream, session); + } finally { + semaphore.release(permits); + } + } else { + onTimeout(); + throw new InterruptedByTimeoutException(); } - } else { - onTimeout(); - throw new InterruptedByTimeoutException(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw (IOException) new InterruptedIOException().initCause(e); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw (IOException) new InterruptedIOException().initCause(e); } + } finally { + onFinish(); } } diff --git a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java index 0bc6829..1650c78 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java +++ b/src/main/java/com/flowingcode/vaadin/addons/gridexporter/GridExporter.java @@ -21,7 +21,9 @@ import com.flowingcode.vaadin.addons.fontawesome.FontAwesome; import com.flowingcode.vaadin.addons.gridhelpers.GridHelper; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.component.HasEnabled; import com.vaadin.flow.component.grid.ColumnPathRenderer; import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.grid.Grid.Column; @@ -50,6 +52,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; @@ -79,6 +82,9 @@ public class GridExporter implements Serializable { public static final float DEFAULT_COST = 1.0f; private static long concurrentDownloadTimeoutNanos = 0L; + + private boolean disableOnClick; + private float concurrentDownloadCost = DEFAULT_COST; private final List> instanceDownloadTimeoutListeners = new CopyOnWriteArrayList<>(); @@ -148,21 +154,25 @@ public static GridExporter createFor( if (exporter.autoAttachExportButtons) { if (exporter.isExcelExportEnabled()) { Anchor excelLink = new Anchor("", FontAwesome.Regular.FILE_EXCEL.create()); - excelLink.setHref(exporter.getExcelStreamResource(excelCustomTemplate)); + excelLink + .setHref(exporter.getExcelStreamResource(excelCustomTemplate) + .forComponent(excelLink)); excelLink.getElement().setAttribute("download", true); footerToolbar.add( new FooterToolbarItem(excelLink, FooterToolbarItemPosition.EXPORT_BUTTON)); } if (exporter.isDocxExportEnabled()) { Anchor docLink = new Anchor("", FontAwesome.Regular.FILE_WORD.create()); - docLink.setHref(exporter.getDocxStreamResource(docxCustomTemplate)); + docLink.setHref( + exporter.getDocxStreamResource(docxCustomTemplate).forComponent(docLink)); docLink.getElement().setAttribute("download", true); footerToolbar .add(new FooterToolbarItem(docLink, FooterToolbarItemPosition.EXPORT_BUTTON)); } if (exporter.isPdfExportEnabled()) { Anchor docLink = new Anchor("", FontAwesome.Regular.FILE_PDF.create()); - docLink.setHref(exporter.getPdfStreamResource(docxCustomTemplate)); + docLink.setHref( + exporter.getPdfStreamResource(docxCustomTemplate).forComponent(docLink)); docLink.getElement().setAttribute("download", true); footerToolbar .add(new FooterToolbarItem(docLink, FooterToolbarItemPosition.EXPORT_BUTTON)); @@ -286,21 +296,21 @@ else if (r.getValueProviders().containsKey("name")) { return value; } - public StreamResource getDocxStreamResource() { + public GridExporterStreamResource getDocxStreamResource() { return getDocxStreamResource(null); } - public StreamResource getDocxStreamResource(String template) { - return new StreamResource(fileName + ".docx", + public GridExporterStreamResource getDocxStreamResource(String template) { + return new GridExporterStreamResource(fileName + ".docx", makeConcurrentWriter(new DocxStreamResourceWriter<>(this, template))); } - public StreamResource getPdfStreamResource() { + public GridExporterStreamResource getPdfStreamResource() { return getPdfStreamResource(null); } - public StreamResource getPdfStreamResource(String template) { - return new StreamResource(fileName + ".pdf", + public GridExporterStreamResource getPdfStreamResource(String template) { + return new GridExporterStreamResource(fileName + ".pdf", makeConcurrentWriter(new PdfStreamResourceWriter<>(this, template))); } @@ -308,41 +318,81 @@ public StreamResource getCsvStreamResource() { return new StreamResource(fileName + ".csv", new CsvStreamResourceWriter<>(this)); } - public StreamResource getExcelStreamResource() { + public GridExporterStreamResource getExcelStreamResource() { return getExcelStreamResource(null); } - public StreamResource getExcelStreamResource(String template) { - return new StreamResource(fileName + ".xlsx", + public GridExporterStreamResource getExcelStreamResource(String template) { + return new GridExporterStreamResource(fileName + ".xlsx", makeConcurrentWriter(new ExcelStreamResourceWriter<>(this, template))); } - private StreamResourceWriter makeConcurrentWriter(StreamResourceWriter writer) { - return new ConcurrentStreamResourceWriter(writer) { - @Override - public float getCost(VaadinSession session) { - return concurrentDownloadCost; - } + private GridExporterConcurrentStreamResourceWriter makeConcurrentWriter( + StreamResourceWriter writer) { + return new GridExporterConcurrentStreamResourceWriter(writer); + } - @Override - public long getTimeout() { - // It would have been possible to specify a different timeout for each instance but I cannot - // figure out a good use case for that. The timeout returned herebecomes relevant when the - // semaphore has been acquired by any other download, so the timeout must reflect how long - // it is reasonable to wait for "any other download" to complete and release the semaphore. - // - // Since the reasonable timeout would depend on the duration of "any other download", it - // makes sense that it's a global setting instead of a per-instance setting. - return concurrentDownloadTimeoutNanos; - } + public class GridExporterStreamResource extends StreamResource { + private final GridExporterConcurrentStreamResourceWriter writer; + + GridExporterStreamResource(String name, GridExporterConcurrentStreamResourceWriter writer) { + super(name, writer); + this.writer = Objects.requireNonNull(writer); + } + + public GridExporterStreamResource forComponent(Component component) { + writer.button = component; + return this; + } + } + + private class GridExporterConcurrentStreamResourceWriter extends ConcurrentStreamResourceWriter { + + GridExporterConcurrentStreamResourceWriter(StreamResourceWriter delegate) { + super(delegate); + } + + private Component button; + + @Override + public float getCost(VaadinSession session) { + return concurrentDownloadCost; + } + + @Override + public long getTimeout() { + // It would have been possible to specify a different timeout for each instance but I cannot + // figure out a good use case for that. The timeout returned herebecomes relevant when the + // semaphore has been acquired by any other download, so the timeout must reflect how long + // it is reasonable to wait for "any other download" to complete and release the semaphore. + // + // Since the reasonable timeout would depend on the duration of "any other download", it + // makes sense that it's a global setting instead of a per-instance setting. + return concurrentDownloadTimeoutNanos; + } + + @Override + protected void onTimeout() { + fireConcurrentDownloadTimeout(); + } - @Override - protected void onTimeout() { - fireConcurrentDownloadTimeout(); + @Override + protected void onAccept() { + if (disableOnClick) { + setButtonEnabled(false); } + } + @Override + protected void onFinish() { + setButtonEnabled(true); + } - }; + private void setButtonEnabled(boolean enabled) { + if (button instanceof HasEnabled) { + grid.getUI().ifPresent(ui -> ui.access(() -> ((HasEnabled) button).setEnabled(enabled))); + } + } } /** @@ -441,6 +491,20 @@ public static void setConcurrentDownloadTimeout(long timeout, TimeUnit unit) { GridExporter.concurrentDownloadTimeoutNanos = unit.toNanos(timeout); } + /** + * Configures the behavior of the system when a download is in progress. + *

+ * When {@code disableOnClick} is set to {@code true}, the system prevents the UI from starting an + * additional download of the same kind while one is already in progress. Downloads from other UIs + * are still allowed. When set to {@code false}, concurrent downloads are permitted. + *

+ * + * @param disableOnClick Whether to prevent additional downloads during an ongoing download. + */ + public void setDisableOnClick(boolean disableOnClick) { + this.disableOnClick = disableOnClick; + } + /** * Sets the cost for concurrent downloads. This cost is used to determine the number of permits * required for downloads to proceed, thereby controlling the concurrency level. At any given diff --git a/src/test/java/com/flowingcode/vaadin/addons/gridexporter/GridExporterBigDatasetDemo.java b/src/test/java/com/flowingcode/vaadin/addons/gridexporter/GridExporterBigDatasetDemo.java index 571e024..fd04062 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/gridexporter/GridExporterBigDatasetDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/gridexporter/GridExporterBigDatasetDemo.java @@ -115,5 +115,8 @@ public GridExporterBigDatasetDemo() throws EncryptedDocumentException, IOExcepti exporter.setConcurrentDownloadCost(9); // end-block + // Prevents additional downloads from starting while one is already in progress + exporter.setDisableOnClick(true); } + } diff --git a/src/test/java/com/flowingcode/vaadin/addons/gridexporter/test/ConcurrentExportTests.java b/src/test/java/com/flowingcode/vaadin/addons/gridexporter/test/ConcurrentExportTests.java index 21f69ff..0e442e1 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/gridexporter/test/ConcurrentExportTests.java +++ b/src/test/java/com/flowingcode/vaadin/addons/gridexporter/test/ConcurrentExportTests.java @@ -38,6 +38,8 @@ private class ConcurrentStreamResourceWriter extends ConfigurableConcurrentStreamResourceWriter { private boolean interruptedByTimeout; + private boolean accepted; + private boolean finished; public ConcurrentStreamResourceWriter(StreamResourceWriter delegate) { super(delegate); @@ -48,6 +50,16 @@ protected void onTimeout() { interruptedByTimeout = true; } + @Override + protected void onAccept() { + accepted = true; + } + + @Override + protected void onFinish() { + finished = true; + } + } private CyclicBarrier barrier;