diff --git a/src/Containers/DropArea/DropArea.tsx b/src/Containers/DropArea/DropArea.tsx index 4a300cb3..78c22dcb 100644 --- a/src/Containers/DropArea/DropArea.tsx +++ b/src/Containers/DropArea/DropArea.tsx @@ -54,7 +54,7 @@ export interface IDropAreaProps extends DropzoneType { dropzoneProps?: DropzoneOptions; } -export const DropArea: React.FC = ({ +export const DropArea = React.forwardRef(({ message = 'Drag & Drop your files here', buttonText = 'Browse Files', onClickHandler = () => null, @@ -66,7 +66,7 @@ export const DropArea: React.FC = ({ width, dropzoneProps = {}, ...props -}): React.ReactElement => { +},dropAreaRef): React.ReactElement => { const { getInputProps, getRootProps, isDragActive, open } = useDropzone({ onDragEnter, onDragLeave, @@ -95,7 +95,7 @@ export const DropArea: React.FC = ({ return ( {() => ( - + = ({ )} ); -}; +}); const RootDiv = styled.div<{ isDisabled: boolean }>` width: fit-content; diff --git a/src/Containers/FileUploadV2/FileUploadV2.stories.tsx b/src/Containers/FileUploadV2/FileUploadV2.stories.tsx new file mode 100644 index 00000000..9e801aa7 --- /dev/null +++ b/src/Containers/FileUploadV2/FileUploadV2.stories.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Meta, Story } from '@storybook/react'; +import { FileUploadV2, IFileUploadV2Props } from './FileUploadV2'; + +export default { + title: 'Components/FileUploadV2', + component: FileUploadV2, + args: { + onFile:(base64StringFile:string)=>{console.log(base64StringFile)}, + dropAreaProps:{ + width:400 + } + }, +} as Meta; + +export const Basic: Story = (args) => ( + +); + +export const TestIsFailure = Basic.bind({}); +TestIsFailure.args = { + ...Basic.args, + isTestIsFailure:true +}; diff --git a/src/Containers/FileUploadV2/FileUploadV2.tsx b/src/Containers/FileUploadV2/FileUploadV2.tsx new file mode 100644 index 00000000..c4861cb4 --- /dev/null +++ b/src/Containers/FileUploadV2/FileUploadV2.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import styled from 'styled-components'; +import { MainInterface, Main } from '@Utils/BaseStyles'; +import { Button } from '@Inputs/Button/Button'; +import { PanelCard } from '../PanelCard/PanelCard'; +import { DropArea, IDropAreaProps } from '../DropArea/DropArea'; +import { useInformativePanels } from './useInformativePanels'; +import { useGetWidth } from './useGetWidth'; + +export interface IFileUploadV2Props + extends MainInterface, + React.HTMLAttributes { + /** if true, failure message will appear even after success operation; its purpose is to test the appearance of the failure message during development */ + isTestIsFailure?: boolean; + /** + * function to process the file read and transformed to a base64 string; default: does nothing + * @param {string} base64String the file read and transformed to a base64 string + */ + onFile?: (base64String: string) => void; + /** time in ms of the presence of the bottom panel informing the result of the operation (sucess or failure); default value: 1500 */ + messageDuration?: number; + dropAreaProps?: IDropAreaProps; +} +/** + * multiple file upload, in parallel, version 2 + */ +export const FileUploadV2: React.FC = ({ + isTestIsFailure = false, + onFile = (base64String: string) => null, + messageDuration = 1500, + dropAreaProps = {}, + ...props +}): React.ReactElement => { + const [panels, onDrop, onCancelUploading] = useInformativePanels( + isTestIsFailure, + onFile, + messageDuration, + ); + const [dropAreaWidth, dropAreaRef] = useGetWidth(); + + return ( + + + {panels.map((panel) => ( + + Cancel + + } + name={panel.name} + operationState={panel.operationState} + margin="10px 0" + style={{ width: dropAreaWidth, boxSizing: 'border-box' }} + /> + ))} + + ); +}; + +const FileUploadV2Container = styled.div` + background-color: ${({ theme }) => theme.colors.background}; + border-radius: ${({ theme }) => theme.dimensions.radius}; + width: fit-content; + ${({ theme, ...props }): string => + Main({ padding: theme.dimensions.padding.container, ...props })} +`; diff --git a/src/Containers/FileUploadV2/useGetWidth.ts b/src/Containers/FileUploadV2/useGetWidth.ts new file mode 100644 index 00000000..fe1824ff --- /dev/null +++ b/src/Containers/FileUploadV2/useGetWidth.ts @@ -0,0 +1,20 @@ +import { useState, useRef, useEffect } from 'react'; + +/** + * gets the width of a component which extends HTMLDivElement props + */ +export const useGetWidth = (): readonly [ + number | undefined, + React.RefObject, +] => { + const [width, setWidth] = useState(); + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + setWidth(ref.current.getBoundingClientRect().width); + } + }, []); + + return [width, ref] as const; +}; diff --git a/src/Containers/FileUploadV2/useInformativePanels.ts b/src/Containers/FileUploadV2/useInformativePanels.ts new file mode 100644 index 00000000..9a2af4e1 --- /dev/null +++ b/src/Containers/FileUploadV2/useInformativePanels.ts @@ -0,0 +1,204 @@ +import {useState,useCallback,useEffect} from 'react' +import { useMounted } from '@Utils/Hooks'; +import {OperationState} from '../PanelCard/PanelCard'; +// @ts-ignore +import worker from 'workerize-loader!./worker'; // eslint-disable-line + +const NO_BASE64STRINGFILE = 'NO_BASE64STRINGFILE'; + +interface IPanel { + /** whether it's loading file, is completed, is failure */ + operationState: OperationState; + /** name of file associated with the informative panel */ + name: string; + /** worker; will do the job of reading the file */ + worker: Worker | null; + /** the file associated with the informative panel */ + file: File | null; +} + +interface IInformativePanels { + /** array of panels */ + panels: IPanel[]; + /** names of files already uploaded, or failed, or cancelled */ + makeItDisappear: string[]; + /** names of files for which we want to start workers */ + startWorkers: string[]; +} + +export const useInformativePanels = ( + isTestIsFailure: boolean, + onFile: (base64StringFile: string) => void, + messageDuration: number, +): readonly [ + IPanel[], + (acceptedFiles: File[]) => void, + (name: string) => () => void, +] => { + const isMounted = useMounted(); + const [informativePanels, setInformativePanels] = + useState({ + panels: [], + makeItDisappear: [], + startWorkers: [], + }); + + /** + * set end state + */ + const prepareForEndInformativePanel = useCallback( + (operationState: OperationState, informativePanel: IPanel): void => { + setInformativePanels((prev) => ({ + ...prev, + panels: prev.panels.map((panel) => { + if (panel.name === informativePanel.name) + return { + ...panel, + operationState, + }; + return panel; + }), + makeItDisappear: [ + ...prev.makeItDisappear, + informativePanel.name, + ], + })); + }, + [], + ); + + /** + * terminate worker and set state of informative panel to success or failure and + * send order to remove informative panel in the future. also do whatever user + * wants to do with the file read in case of success + */ + const onWorkerMessage = useCallback( + (e: any) => { + const { base64StringFile, name } = e.data; + if (base64StringFile === undefined) { + return; + } + const informativePanel = informativePanels.panels.find( + (panel) => panel.name === name, + ); + if (informativePanel) { + if ( + base64StringFile === NO_BASE64STRINGFILE || + isTestIsFailure + ) { + prepareForEndInformativePanel( + OperationState.isFailure, + informativePanel, + ); + } else { + onFile(base64StringFile); + prepareForEndInformativePanel( + OperationState.isSuccess, + informativePanel, + ); + } + } + }, + [ + informativePanels.panels, + isTestIsFailure, + onFile, + prepareForEndInformativePanel, + ], + ); + + // start workers after files have been droped and array of informative panels + // are loaded + useEffect(() => { + if (informativePanels.startWorkers.length) { + informativePanels.startWorkers.forEach((name) => { + const informativePanel = informativePanels.panels.find( + (panel) => panel.name === name, + ); + if (informativePanel && informativePanel.worker) { + informativePanel.worker.onmessage = onWorkerMessage; + informativePanel.worker?.postMessage({ + file: informativePanel.file, + }); + } + }); + setInformativePanels((prev) => ({ + ...prev, + startWorkers: [], + })); + } + }, [informativePanels.startWorkers.length]); + + // make disappear informative panels in the future + useEffect(() => { + if (informativePanels.makeItDisappear.length) { + informativePanels.makeItDisappear.forEach((name) => { + setTimeout(() => { + if (isMounted.current) { + setInformativePanels((prev) => ({ + ...prev, + panels: prev.panels.filter((panel) => { + if (panel.name === name) { + panel.worker?.terminate(); + return false; + } + return true; + }), + makeItDisappear: prev.makeItDisappear.filter( + (name_) => name_ !== name, + ), + })); + } + }, messageDuration); + }); + } + }, [informativePanels.makeItDisappear.length]); + + // terminate workers on clean up function + useEffect( + () => () => { + if (!isMounted.current) { + informativePanels.panels.forEach((panel) => + panel.worker?.terminate(), + ); + } + }, + [informativePanels.panels], + ); + + /** + * load array of informative panels and send order to start workers + */ + const onDrop = useCallback((acceptedFiles: File[]) => { + const newInformativePanels = acceptedFiles.map((file) => { + const workerInstance = worker(); + return { + operationState: OperationState.isLoading, + name: file.name, + worker: workerInstance, + file, + }; + }); + const fileNames = acceptedFiles.map((file) => file.name); + setInformativePanels((prev) => ({ + ...prev, + panels: [...prev.panels, ...newInformativePanels], + startWorkers: [...fileNames], + })); + }, []); + + const onCancelUploading = (name: string) => () => { + setInformativePanels((prev) => ({ + ...prev, + panels: prev.panels.filter((panel) => { + if (panel.name === name) { + panel.worker?.terminate(); + return false; + } + return true; + }), + })); + }; + + return [informativePanels.panels, onDrop, onCancelUploading] as const; +}; diff --git a/src/Containers/FileUploadV2/worker.ts b/src/Containers/FileUploadV2/worker.ts new file mode 100644 index 00000000..17a094d9 --- /dev/null +++ b/src/Containers/FileUploadV2/worker.ts @@ -0,0 +1,24 @@ +onmessage = (e) => { + const { file } = e.data; + const reader = new FileReader(); + reader.onload = () => { + let base64StringFile = 'NO_BASE64STRINGFILE'; + if (reader.result) { + if (typeof reader.result === 'string') { + base64StringFile = btoa(reader.result); + } else { + const bytes = Array.from(new Uint8Array(reader.result)); + base64StringFile = btoa( + bytes.map((item) => String.fromCharCode(item)).join(''), + ); + } + } + postMessage({ base64StringFile,name:file.name }); + }; + try{ + reader.readAsArrayBuffer(file); + }catch(error){ + console.log(error); + postMessage({}); + } +}; \ No newline at end of file diff --git a/src/Containers/index.ts b/src/Containers/index.ts index 77225ceb..0083d9c6 100644 --- a/src/Containers/index.ts +++ b/src/Containers/index.ts @@ -99,3 +99,4 @@ export * from './StoreSelector/StoreSelector'; export * from './ScreenFlashEffect/ScreenFlashEffect'; export * from './PanelCard/PanelCard'; export * from './DropArea/DropArea'; +export * from './FileUploadV2/FileUploadV2';