From 51d1c3bbc8f212215a29a4a8cd01c031d344abcf Mon Sep 17 00:00:00 2001 From: David Arranz Date: Wed, 21 Aug 2024 13:49:52 +0200 Subject: [PATCH] . --- client/package.json | 1 - client/src/app/quotes/hooks/useQuotes.tsx | 37 ++- client/src/lib/hooks/index.ts | 1 + client/src/lib/hooks/useDownloader/index.ts | 2 + client/src/lib/hooks/useDownloader/types.ts | 81 +++++ .../lib/hooks/useDownloader/useDownloader.tsx | 276 ++++++++++++++++++ 6 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 client/src/lib/hooks/useDownloader/index.ts create mode 100644 client/src/lib/hooks/useDownloader/types.ts create mode 100644 client/src/lib/hooks/useDownloader/useDownloader.tsx diff --git a/client/package.json b/client/package.json index afb072e..f552964 100644 --- a/client/package.json +++ b/client/package.json @@ -67,7 +67,6 @@ "react-router-dom": "^6.26.0", "react-secure-storage": "^1.3.2", "react-toastify": "^10.0.5", - "react-use-downloader": "^1.2.8", "react-wrap-balancer": "^1.1.1", "recharts": "^2.12.7" }, diff --git a/client/src/app/quotes/hooks/useQuotes.tsx b/client/src/app/quotes/hooks/useQuotes.tsx index e9dd26d..da918a7 100644 --- a/client/src/app/quotes/hooks/useQuotes.tsx +++ b/client/src/app/quotes/hooks/useQuotes.tsx @@ -1,3 +1,4 @@ +import { useDownloader } from "@/lib/hooks"; import { UseListQueryResult, useCustom, useList, useOne, useSave } from "@/lib/hooks/useDataSource"; import { IFilterItemDataProviderParam, @@ -18,8 +19,7 @@ import { UniqueID, } from "@shared/contexts"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; -import useDownloader from "react-use-downloader"; +import { useCallback, useMemo, useState } from "react"; export type UseQuotesListParams = Omit & { status?: string; @@ -142,7 +142,7 @@ export const useQuotes = () => { ...params, }), - useReport: (id?: string, params?: UseQuotesReportParamsType) => { + useReport2: (id?: string, params?: UseQuotesReportParamsType) => { const queryClient = useQueryClient(); const queryKey = useMemo( () => keys().data().resource("quotes").action("report").id(id).params().get(), @@ -221,6 +221,37 @@ export const useQuotes = () => { return { download, error, isFetching, isError }; },*/ + useReport: () => { + const auth = dataSource.getApiAuthorization(); + const [reportBlob, setReportBlob] = useState(); + + const downloader = useDownloader({ + headers: { + Authorization: auth, + }, + customHandleDownload: (data: Blob) => { + const blobData = [data]; + const blob = new Blob(blobData, { + type: "application/pdf", + }); + setReportBlob(blob); + + return true; + }, + }); + + const download = (id: string) => { + const url = actions.getQuotePDFDownloadURL(id); + downloader.download(url, ""); + return reportBlob; + }; + + return { + ...downloader, + download, + }; + }, + useDownloader: () => { const auth = dataSource.getApiAuthorization(); const downloader = useDownloader({ diff --git a/client/src/lib/hooks/index.ts b/client/src/lib/hooks/index.ts index 16ebad0..aeebf52 100644 --- a/client/src/lib/hooks/index.ts +++ b/client/src/lib/hooks/index.ts @@ -15,6 +15,7 @@ export * from "./useAuth"; export * from "./useCustomDialog"; export * from "./useDataSource"; export * from "./useDataTable"; +export * from "./useDownloader"; export * from "./useLocalization"; export * from "./useMediaQuery"; export * from "./usePagination"; diff --git a/client/src/lib/hooks/useDownloader/index.ts b/client/src/lib/hooks/useDownloader/index.ts new file mode 100644 index 0000000..1bc60a1 --- /dev/null +++ b/client/src/lib/hooks/useDownloader/index.ts @@ -0,0 +1,2 @@ +import useDownloader from "./useDownloader"; +export { useDownloader }; diff --git a/client/src/lib/hooks/useDownloader/types.ts b/client/src/lib/hooks/useDownloader/types.ts new file mode 100644 index 0000000..123470a --- /dev/null +++ b/client/src/lib/hooks/useDownloader/types.ts @@ -0,0 +1,81 @@ +import { SetStateAction } from "react"; + +export type ErrorMessage = { + errorMessage: string; +} | null; + +/** useDownloader options for fetch call + * See fetch RequestInit for more details + */ +export type UseDownloaderOptions = RequestInit & { + customHandleDownload?: (data: Blob, filename: string, mime?: string) => boolean | NodeJS.Timeout; +}; + +/** + * Initiate the download of the specified asset from the specified url. Optionally supply timeout and overrideOptions. + * @example await download('https://example.com/file.zip', 'file.zip') + * @example await download('https://example.com/file.zip', 'file.zip', 500) timeouts after 500ms + * @example await download('https://example.com/file.zip', 'file.zip', undefined, { method: 'GET' }) skips optional timeout but supplies overrideOptions + */ +export type DownloadFunction = ( + /** Download url + * @example https://upload.wikimedia.org/wikipedia/commons/4/4d/%D0%93%D0%BE%D0%B2%D0%B5%D1%80%D0%BB%D0%B0_%D1%96_%D0%9F%D0%B5%D1%82%D1%80%D0%BE%D1%81_%D0%B2_%D0%BF%D1%80%D0%BE%D0%BC%D1%96%D0%BD%D1%8F%D1%85_%D0%B2%D1%80%D0%B0%D0%BD%D1%96%D1%88%D0%BD%D1%8C%D0%BE%D0%B3%D0%BE_%D1%81%D0%BE%D0%BD%D1%86%D1%8F.jpg + */ + downloadUrl: string, + /** File name + * @example carpathia.jpeg + */ + filename: string, + /** Optional timeout to download items */ + timeout?: number, + /** Optional options to supplement and/or override UseDownloader options */ + overrideOptions?: UseDownloaderOptions +) => Promise; + +/** + * Provides access to Downloader functionality and settings. + * + * @interface EditDialogField + * @field {number} size in bytes. + * @field {number} elapsed time in seconds. + * @field {number} percentage in string + * @field {DownloadFunction} download function handler + * @field {void} cancel function handler + * @field {ErrorMessage} error object from the request + * @field {boolean} isInProgress boolean flag denoting download status + */ +export interface UseDownloader { + /** Size in bytes */ + size: number; + /** Elapsed time in seconds */ + elapsed: number; + /** Percentage in string */ + percentage: number; + /** + * Download function handler + * @example await download('https://example.com/file.zip', 'file.zip') + * @example await download('https://example.com/file.zip', 'file.zip', 500) timeouts after 500ms + * */ + download: DownloadFunction; + /** Cancel function handler */ + cancel: () => void; + /** Error object from the request */ + error: ErrorMessage; + /** Boolean denoting download status */ + isInProgress: boolean; +} + +export interface ResolverProps { + setSize: (value: SetStateAction) => void; + setControllerCallback: (controller: ReadableStreamController) => void; + setPercentageCallback: ({ loaded, total }: { loaded: number; total: number }) => void; + setErrorCallback: (err: Error) => void; +} + +interface CustomNavigator extends Navigator { + msSaveBlob: (blob?: Blob, filename?: string) => boolean | NodeJS.Timeout; +} + +export interface WindowDownloaderEmbedded extends Window { + navigator: CustomNavigator; +} diff --git a/client/src/lib/hooks/useDownloader/useDownloader.tsx b/client/src/lib/hooks/useDownloader/useDownloader.tsx new file mode 100644 index 0000000..63dc8d8 --- /dev/null +++ b/client/src/lib/hooks/useDownloader/useDownloader.tsx @@ -0,0 +1,276 @@ +// https://github.com/the-bugging/react-use-downloader + +import { useCallback, useMemo, useRef, useState } from "react"; +import { + DownloadFunction, + ErrorMessage, + ResolverProps, + UseDownloader, + UseDownloaderOptions, + WindowDownloaderEmbedded, +} from "./types"; + +/** + * Resolver function to handle the download progress. + * @param {ResolverProps} props + * @returns {Response} + */ +export const resolver = + ({ setSize, setControllerCallback, setPercentageCallback, setErrorCallback }: ResolverProps) => + (response: Response): Response => { + if (!response.ok) { + throw Error(`${response.status} ${response.type} ${response.statusText}`); + } + + if (!response.body) { + throw Error("ReadableStream not yet supported in this browser."); + } + + const responseBody = response.body; + + const contentEncoding = response.headers.get("content-encoding"); + const contentLength = response.headers.get(contentEncoding ? "x-file-size" : "content-length"); + + const total = parseInt(contentLength || "0", 10); + + setSize(() => total); + + let loaded = 0; + + const stream = new ReadableStream({ + start(controller) { + setControllerCallback(controller); + + const reader = responseBody.getReader(); + + async function read(): Promise { + return reader + .read() + .then(({ done, value }) => { + if (done) { + return controller.close(); + } + + loaded += value?.byteLength || 0; + + if (value) { + controller.enqueue(value); + } + + setPercentageCallback({ loaded, total }); + + return read(); + }) + .catch((error: Error) => { + setErrorCallback(error); + reader.cancel("Cancelled"); + + return controller.error(error); + }); + } + + return read(); + }, + }); + + return new Response(stream); + }; + +/** + * jsDownload function to handle the download process. + * @param {Blob} data + * @param {string} filename + * @param {string} mime + * @returns {boolean | NodeJS.Timeout} + */ +export const jsDownload = ( + data: Blob, + filename: string, + mime?: string +): boolean | NodeJS.Timeout => { + const blobData = [data]; + const blob = new Blob(blobData, { + type: mime || "application/octet-stream", + }); + + if (typeof (window as unknown as WindowDownloaderEmbedded).navigator.msSaveBlob !== "undefined") { + return (window as unknown as WindowDownloaderEmbedded).navigator.msSaveBlob(blob, filename); + } + + const blobURL = + window.URL && window.URL.createObjectURL + ? window.URL.createObjectURL(blob) + : window.webkitURL.createObjectURL(blob); + const tempLink = document.createElement("a"); + tempLink.style.display = "none"; + tempLink.href = blobURL; + tempLink.setAttribute("download", filename); + + if (typeof tempLink.download === "undefined") { + tempLink.setAttribute("target", "_blank"); + } + + document.body.appendChild(tempLink); + tempLink.click(); + + return setTimeout(() => { + document.body.removeChild(tempLink); + window.URL.revokeObjectURL(blobURL); + }, 200); +}; + +/** + * useDownloader hook to handle the download process. + * @param {UseDownloaderOptions} options + * @returns {UseDownloader} + */ +export default function useDownloader({ + customHandleDownload, + ...options +}: UseDownloaderOptions = {}): UseDownloader { + let debugMode = false; + try { + debugMode = process ? !!process?.env?.REACT_APP_DEBUG_MODE : false; + } catch { + debugMode = false; + } + + const [elapsed, setElapsed] = useState(0); + const [percentage, setPercentage] = useState(0); + const [size, setSize] = useState(0); + const [error, setError] = useState(null); + const [isInProgress, setIsInProgress] = useState(false); + + const controllerRef = useRef>(null); + + const setPercentageCallback = useCallback( + ({ loaded, total }: { loaded: number; total: number }) => { + const pct = Math.round((loaded / total) * 100); + + setPercentage(() => pct); + }, + [] + ); + + const setErrorCallback = useCallback((err: Error) => { + const errorMap: { [index: string]: string } = { + "Failed to execute 'enqueue' on 'ReadableStreamDefaultController': Cannot enqueue a chunk into an errored readable stream": + "Download canceled", + "The user aborted a request.": "Download timed out", + }; + setError(() => { + const resolvedError: string = errorMap[err.message] ? errorMap[err.message] : err.message; + + return { errorMessage: resolvedError }; + }); + }, []); + + const setControllerCallback = useCallback( + (controller: ReadableStreamController | null) => { + controllerRef.current = controller; + }, + [] + ); + + const closeControllerCallback = useCallback(() => { + if (controllerRef.current) { + controllerRef.current.error(); + } + }, []); + + const clearAllStateCallback = useCallback(() => { + setControllerCallback(null); + + setElapsed(() => 0); + setPercentage(() => 0); + setSize(() => 0); + setIsInProgress(() => false); + }, [setControllerCallback]); + + const handleDownload: DownloadFunction = useCallback( + async (downloadUrl, filename, timeout = 0, overrideOptions = {}) => { + if (isInProgress) return null; + + clearAllStateCallback(); + setError(() => null); + setIsInProgress(() => true); + + const intervalId = setInterval( + () => setElapsed((prevValue) => prevValue + 1), + debugMode ? 1 : 1000 + ); + const resolverWithProgress = resolver({ + setSize, + setControllerCallback, + setPercentageCallback, + setErrorCallback, + }); + + const fetchController = new AbortController(); + const timeoutId = setTimeout(() => { + if (timeout > 0) fetchController.abort(); + }, timeout); + + // Use the custom handle download function if available + const _customHandleDownload = customHandleDownload || jsDownload; + + return fetch(downloadUrl, { + method: "GET", + ...options, + ...overrideOptions, + signal: fetchController.signal, + }) + .then(resolverWithProgress) + .then((data) => { + return data.blob(); + }) + .then((response) => _customHandleDownload(response, filename)) + .then(() => { + clearAllStateCallback(); + + return clearInterval(intervalId); + }) + .catch((err) => { + clearAllStateCallback(); + setError((prevValue) => { + const { message } = err; + + if (message !== "Failed to fetch") { + return { + errorMessage: err.message, + }; + } + + return prevValue; + }); + + clearTimeout(timeoutId); + return clearInterval(intervalId); + }); + }, + [ + isInProgress, + clearAllStateCallback, + debugMode, + setControllerCallback, + setPercentageCallback, + setErrorCallback, + options, + customHandleDownload, + ] + ); + + return useMemo( + () => ({ + elapsed, + percentage, + size, + download: handleDownload, + cancel: closeControllerCallback, + error, + isInProgress, + jsDownload, + }), + [elapsed, percentage, size, handleDownload, closeControllerCallback, error, isInProgress] + ); +}