Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Download progress indicator #598 #1677

Draft
wants to merge 7 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions assets/css/flash.scss
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
#flash-messages {
position: absolute;
top: 2rem;
top: 12rem;
right: 2rem;
display: flex;
flex-direction: column-reverse;
gap: 2rem;
background-color: transparent;
pointer-events: none;

.toast {
margin: 10rem 0.3rem 1.3rem 0;

&.fade {
opacity: 1;
display: block;
}
}
}
5 changes: 5 additions & 0 deletions assets/css/spinner.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@
display: inline-block;
opacity: 1;
}

.toast .spinner-border {
display: inline-block;
opacity: 1;
}
15 changes: 10 additions & 5 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@ import collapseSubSidebar from "./ui/collapsible_sub_sidebar.js";
import fileUpload from "./ui/file_upload.js";
import tags from "./ui/tags.js";
import facetDropdowns from "./ui/facet_dropdowns.js";
import fileDownload from "./ui/file_download.js";

// configure htmx
htmx.config.defaultFocusScroll = true;

// apply bootstrap js to new dom content
htmx.onLoad(initCallback);
htmx.onLoad((el) => {
// apply Bootstrap JS to new DOM content (not on initial load)
if (el !== document.body) {
initCallback(el);
}
});

// load htmx extensions
window.htmx = htmx;
require("htmx-ext-remove-me");
// load htmx extensions (uncomment if any HTMX extensions are needed)
// window.htmx = htmx;

// initialize everything
document.addEventListener("DOMContentLoaded", function () {
Expand All @@ -49,4 +53,5 @@ document.addEventListener("DOMContentLoaded", function () {

htmx.onLoad(function (el) {
clipboard(el);
fileDownload(el);
});
71 changes: 71 additions & 0 deletions assets/js/ui/file_download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import showFlashMessage from "./flash_message";

export default function fileDownload(el) {
el.querySelectorAll("a[download]").forEach((anchor) => {
anchor.addEventListener("click", async (e) => {
e.preventDefault();

const flashMessage = showFlashMessage({
isLoading: true,
text: "Preparing download...",
isDismissible: false,
toastOptions: {
autohide: false,
},
});

try {
const response = await fetch(anchor.href);
if (!response.ok) {
throw new Error(
"An unexpected error occurred while downloading the file. Please try again.",
);
}

const filename = extractFileName(response);
const blob = await response.blob();

const dummyAnchor = document.createElement("a");
dummyAnchor.href = URL.createObjectURL(blob);
dummyAnchor.setAttribute("class", "link-primary text-nowrap");
dummyAnchor.setAttribute("download", filename);
dummyAnchor.textContent = filename;

flashMessage?.setLevel("success");
flashMessage?.setIsLoading(false);
flashMessage?.setText("Download ready: " + dummyAnchor.outerHTML);
flashMessage?.setIsDismissible(true);

// Trigger download (save-as window or auto-download, depending on browser settings)
dummyAnchor.click();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually a hack that's used widespread. The click on the <a> tag is prevented at the top of this function so we can intervene and display the toast first. Then the file is downloaded via the fetch() API. When ready, we create a dummy anchor tag using a data URL with the downloaded file. At the end, the click() method is triggered on that which will actually present the download to the browser. The dummy anchor is also included in the updated toast, but that is not necessary. Just an alternate way to get the file, in case you canceled the save-as-dialog.

Tested in Chrome, Firefox, Safari and Edge.

} catch (error) {
if (flashMessage) {
flashMessage.hide();
}

showFlashMessage({
level: "error",
text: error.message,
});
}
});
});
}

function extractFileName(response: Response) {
const FILENAME_REGEX = /filename\*?=(UTF-8'')?/;
const contentDispositionHeader = response.headers.get("content-disposition");

if (contentDispositionHeader) {
const fileNamePart = contentDispositionHeader
.split(";")
.find((n) => n.match(FILENAME_REGEX));

if (fileNamePart) {
const fileName = fileNamePart.replace(FILENAME_REGEX, "").trim();
return decodeURIComponent(fileName);
}
}

return "";
}
143 changes: 143 additions & 0 deletions assets/js/ui/flash_message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Toast, type ToastOptions } from "bootstrap.native";

const levelMap = {
success: "success",
info: "primary",
warning: "warning",
error: "error",
} as const;

type Level = keyof typeof levelMap;

type FlashMessageOptions = {
level?: Level;
isLoading?: boolean;
title?: string;
text: string;
isDismissible?: boolean;
toastOptions?: Partial<ToastOptions>;
};

export default function showFlashMessage({
level,
isLoading = false,
title,
text,
isDismissible = true,
toastOptions,
}: FlashMessageOptions) {
let flashMessages = document.querySelector("#flash-messages");
if (flashMessages) {
/*
* Expects somewhere in the document ..
*
* <template id="template-flash-message"></template>
*
* .. a template that encapsulates the toast
* */
const templateFlashMessage = document.querySelector<HTMLTemplateElement>(
"template#template-flash-message",
);

if (templateFlashMessage) {
const toastEl = (
templateFlashMessage.content.cloneNode(true) as HTMLElement
).querySelector<HTMLElement>(".toast");

if (toastEl) {
const flashMessage = new FlashMessage(toastEl);

flashMessage.setLevel(level);
flashMessage.setIsLoading(isLoading);
flashMessage.setTitle(title);
flashMessage.setText(text);
flashMessage.setIsDismissible(isDismissible);

flashMessages.appendChild(toastEl);

flashMessage.show(toastOptions);

return flashMessage;
}
}
}
}

class FlashMessage {
#toastEl: HTMLElement;
#toast: Toast | null;

constructor(readonly toastEl: HTMLElement) {
this.#toastEl = toastEl;
}

setLevel(level: Level | undefined) {
this.#toastEl.querySelectorAll(".toast-body i.if").forEach((el) => {
el.classList.add("d-none");
});

if (level && Object.keys(levelMap).includes(level)) {
this.#toastEl
.querySelector(`.if--${levelMap[level]}`)
?.classList.remove("d-none");
}
}

setIsLoading(isLoading: boolean) {
this.#toastEl
.querySelector(".spinner-border")
?.classList.toggle("d-none", !isLoading);
}

setTitle(title: string | undefined) {
const titleEl = this.#toastEl.querySelector(".alert-title");
if (titleEl) {
if (title) {
titleEl.classList.remove("d-none");
titleEl.textContent = title;
} else {
titleEl.classList.add("d-none");
}
}
}

setText(text: string) {
const textEl = this.#toastEl.querySelector(".toast-text");
if (textEl) {
textEl.innerHTML = text;
}
}

setIsDismissible(isDismissible: boolean) {
const btnClose = this.#toastEl.querySelector(".btn-close");
if (btnClose) {
btnClose.classList.toggle("d-none", !isDismissible);
}
}

setAutohide(autohide: boolean, delay = 5000) {
if (this.#toast) {
this.#toast.options.autohide = autohide;
this.#toast.options.delay = delay;
this.#toast.show();
}
}

show(toastOptions: Partial<ToastOptions> = {}) {
this.#toast =
Toast.getInstance(this.#toastEl) ??
new Toast(this.#toastEl, toastOptions);

this.#toast.show();

this.#toastEl.addEventListener("hidden.bs.toast", () => {
this.#toastEl.remove();
});
}

hide() {
if (this.#toast) {
this.#toast.hide();
}
}
}
51 changes: 32 additions & 19 deletions assets/js/ui/modal_error.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,43 @@
import { Modal } from "bootstrap.native";

export default function (error) {
let modals = document.querySelector("#modals");
if (modals) {
/*
* Expects somewhere in the document ..
*
* <template id="template-modal-error"></template>
*
* .. a template that encapsulates the modal body
* */
const templateModalError = document.querySelector(
"template#template-modal-error",
);

if (!modals) return;
if (templateModalError) {
const modalEl = templateModalError.content
.cloneNode(true)
.querySelector(".modal");

/*
* Expects somewhere in the document ..
*
* <template class="template-modal-error"></template>
*
* .. a template that encapsulates the modal body
* */
let templateModalError = document.querySelector(
"template.template-modal-error",
);
modalEl.querySelector(".msg").textContent = error;

if (!templateModalError) return;
modals.innerHTML = "";
modals.appendChild(modalEl);

let modal = templateModalError.content.cloneNode(true);
initModal(modalEl);
}
}
}

// modal-close not triggered for dynamically added modals
modal.querySelector(".modal-close").addEventListener("click", function () {
modals.innerHTML = "";
function initModal(modalEl) {
const modal = new Modal(modalEl, {
backdrop: "static",
keyboard: false,
});

modal.querySelector(".msg").textContent = error;
modal.show();

modals.innerHTML = "";
modals.appendChild(modal);
modalEl.addEventListener("hidden.bs.modal", function () {
modalEl.remove();
});
}
34 changes: 14 additions & 20 deletions assets/js/ui/popover.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
import htmx from "htmx.org/dist/htmx.esm.js";
import { Popover } from "bootstrap.native";
import Popover from "bootstrap.native/popover";

export default function () {
let addEvents = function (rootEl) {
rootEl
.querySelectorAll("[data-bs-toggle=popover-custom]")
.forEach(function (el) {
let container = document.querySelector(el.dataset.popoverContent);
let content = container.querySelector(".popover-body");
let heading = container.querySelector(".popover-heading");
let title = "";
if (heading) {
title = heading.innerHTML;
}
new Popover(el, {
content: content.innerHTML,
title: title,
delay: 1000,
});
});
};
htmx.onLoad((rootEl) => {
rootEl.querySelectorAll("[data-bs-toggle=popover-custom]").forEach((el) => {
const container = document.querySelector(el.dataset.popoverContent);
const content = container.querySelector(".popover-body");
const heading = container.querySelector(".popover-heading");
const title = heading?.innerHTML ?? "";

htmx.onLoad(addEvents);
new Popover(el, {
content: content.innerHTML,
title,
delay: 1000,
});
});
});
}
Loading