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

Improve the file download experience for the uploaded files in the Library Page #1121

Merged
merged 1 commit into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
151 changes: 9 additions & 142 deletions src/api/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,148 +168,6 @@ export interface IFileBlob {
fileType: string;
}

export class FileViewerModel {
static downloadStatus: any = {};
static itemsToDownload: FeedFile[] = [];
static abortControllers: any = {};

static getFileName(item: FeedFile) {
const splitString = item.data.fname.split("/");
const filename = splitString[splitString.length - 1];
return filename;
}

static setDownloadStatus(status: number, item: FeedFile) {
this.downloadStatus = {
...this.downloadStatus,
[item.data.fname]: status,
};
}

static startDownload(
item: FeedFile,
notification: any,
callback: (status: any) => void,
) {
const findItem = this.itemsToDownload.find(
(currentItem) => currentItem.data.fname === item.data.fname,
);

const filename = this.getFileName(item);

const onDownloadProgress = (progress: any, item: FeedFile) => {
this.downloadStatus = {
...this.downloadStatus,
[item.data.fname]: progress,
};
callback(this.downloadStatus);
};

if (!findItem) {
this.itemsToDownload.push(item);
this.setDownloadStatus(0, item);
callback(this.downloadStatus);
notification.info({
message: `Preparing ${filename} for download.`,
description: `Total Jobs (${this.itemsToDownload.length})`,
duration: 5,
});

this.downloadFile(
item,
filename,
notification,
callback,
onDownloadProgress,
);
}
}

static removeJobs(
item: FeedFile,
notification: any,
callback: (status: any) => void,
status: string,
) {
const index = this.itemsToDownload.indexOf(item);
if (index > -1) {
// only splice array when item is found
this.itemsToDownload.splice(index, 1); // 2nd parameter means remove one item only
}

delete this.downloadStatus[item.data.fname];
delete this.abortControllers[item.data.fname];
const filename = this.getFileName(item);

callback(this.downloadStatus);
notification.info({
message: `${status} download for ${filename}`,
description: `Total jobs ${this.itemsToDownload.length}`,
duration: 1.5,
});
}

// Download File Blob
static async downloadFile(
item: FeedFile,
filename: string,
notification: any,
callback: (status: any) => void,
onDownloadProgressCallback: (progressEvent: number, item: FeedFile) => void,
) {
const urlString = `${item.url}${filename}`;
const client = ChrisAPIClient.getClient();
const token = client.auth.token;
const controller = new AbortController();
const { signal } = controller;

this.abortControllers = {
...this.abortControllers,
[item.data.fname]: controller,
};

const downloadPromise = axios
.get(urlString, {
responseType: "blob",
headers: {
Authorization: `Token ${token}`,
},
signal,
onDownloadProgress: (progressEvent: AxiosProgressEvent) => {
if (progressEvent.loaded) {
const progress = Math.floor(
(progressEvent.loaded / item.data.fsize) * 100,
);

onDownloadProgressCallback(progress, item);
}
},
})
.catch((error) => {
this.removeJobs(item, notification, callback, error);
return null;
});

const response = await downloadPromise;

if (response?.data) {
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.target = "_blank";
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
setTimeout(function () {
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
}, 1000);
this.removeJobs(item, notification, callback, "Finished");
}
}
}

// Description: Mapping for Viewer type by file type *Note: Should come from db
// File type: Viewer component name
export const fileViewerMap: any = {
Expand Down Expand Up @@ -345,3 +203,12 @@ export function getFileExtension(filename: string) {
const name = filename.substring(filename.lastIndexOf(".") + 1);
return name;
}

// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
export class FileViewerModel {
public getFileName(item: FeedFile) {
const splitString = item.data.fname.split("/");
const filename = splitString[splitString.length - 1];
return filename;
}
}
114 changes: 72 additions & 42 deletions src/components/LibraryCopy/FileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,22 @@ import {
Card,
CardBody,
CardHeader,
Split,
SplitItem,
Progress,
Modal,
ModalVariant,
Progress,
Split,
SplitItem,
} from "@patternfly/react-core";
import { useMutation } from "@tanstack/react-query";
import { notification } from "antd";
import { useContext, useState } from "react";
import FaFile from "@patternfly/react-icons/dist/esm/icons/file-icon";
import FaDownload from "@patternfly/react-icons/dist/esm/icons/download-icon";
import AiOutlineClose from "@patternfly/react-icons/dist/esm/icons/close-icon";
import useLongPress from "./utils";
import FileDetailView from "../Preview/FileDetailView";
import axios, { AxiosProgressEvent } from "axios";
import { useContext, useEffect, useState } from "react";
import ChrisAPIClient from "../../api/chrisapiclient";
import { FileViewerModel } from "../../api/model";
import { DotsIndicator } from "../Common";
import { elipses } from "./utils";
import { DownloadIcon, FileIcon } from "../Icons";
import FileDetailView from "../Preview/FileDetailView";
import { LibraryContext } from "./context";
import useLongPress, { elipses } from "./utils";

const FileCard = ({ file }: { file: any }) => {
const { state } = useContext(LibraryContext);
Expand All @@ -35,6 +34,59 @@ const FileCard = ({ file }: { file: any }) => {
const fileName = fileNameArray[fileNameArray.length - 1];
const { previewAll } = state;

const downloadFile = useMutation({
mutationFn: () => {
const url = file.collection.items[0].links[0].href;
if (!url) {
throw new Error("Count not fetch the file from this url");
}
const client = ChrisAPIClient.getClient();
const token = client.auth.token;

const downloadPromise = axios.get(url, {
headers: {
Authorization: `Token ${token}`,
},
onDownloadProgress: (progressEvent: AxiosProgressEvent) => {
if (progressEvent?.progress) {
setDownloadStatus(progressEvent.progress * 100);
}
},
});
return downloadPromise;
},
});

useEffect(() => {
if (downloadFile.isSuccess) {
const { data: response } = downloadFile;
const link = document.createElement("a");
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const fileViewer = new FileViewerModel();
const fileName = fileViewer.getFileName(file);
link.target = "_blank";
link.href = url;
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
notification.info({
message: `Triggered download for ${fileName}`,
duration: 0.5,
});
downloadFile.reset();
setDownloadStatus(-1);
}

if (downloadFile.isError) {
notification.error({
message: `${downloadFile.error.message}`,
duration: 2,
});
downloadFile.reset();
}
}, [downloadFile.isSuccess, downloadFile.isError]);

return (
<Card
onClick={(e) => {
Expand All @@ -52,9 +104,9 @@ const FileCard = ({ file }: { file: any }) => {
isRounded
>
<CardHeader>
<Split style={{ overflow: "hidden" }}>
<Split style={{ overflow: "hidden", alignItems: "center" }}>
<SplitItem>
<FaFile />
<FileIcon />
</SplitItem>
<SplitItem isFilled>
<Button variant="link">{elipses(fileName, 40)}</Button>
Expand All @@ -78,25 +130,13 @@ const FileCard = ({ file }: { file: any }) => {
<Button
style={{ marginLeft: "0.5rem" }}
variant="link"
icon={
<FaDownload
style={{ cursor: "pointer" }}
onClick={async (event) => {
event.stopPropagation();
FileViewerModel.startDownload(
file,
notification,
(statusCallbackValue: any) => {
const statusValue = statusCallbackValue[file.data.fname];
setDownloadStatus(statusValue);
},
);
}}
/>
}
onClick={async (event) => {
event.stopPropagation();
downloadFile.mutate();
}}
icon={<DownloadIcon style={{ cursor: "pointer" }} />}
/>
{status === 0 && <DotsIndicator title="Processing Download..." />}
{status && status > 0 ? (
{status > 0 && (
<div
style={{
display: "flex",
Expand All @@ -110,19 +150,9 @@ const FileCard = ({ file }: { file: any }) => {
}}
size="sm"
value={status}
/>{" "}
<AiOutlineClose
style={{
color: "red",
marginLeft: "0.25rem",
}}
onClick={(event) => {
event.stopPropagation();
FileViewerModel.abortControllers[file.data.fname].abort();
}}
/>
</div>
) : null}
)}
</div>
</CardBody>
{largePreview && (
Expand Down
3 changes: 2 additions & 1 deletion src/components/Preview/displays/DcmDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ const DcmDisplay: React.FC<DcmImageProps> = (props: DcmImageProps) => {
if (extension === "dcm") {
imageID = await loadDicomImage(blob);
} else {
const fileName = FileViewerModel.getFileName(file);
const fileviewer = new FileViewerModel();
const fileName = fileviewer.getFileName(file);
imageID = `web:${file.url}${fileName}`;
}
const activeViewport = await displayDicomImage(element, imageID);
Expand Down