From a8451d51dcdefc0255ab0f510c1e353eee3de44b Mon Sep 17 00:00:00 2001 From: ameerul-deriv <103412909+ameerul-deriv@users.noreply.github.com> Date: Thu, 4 Jan 2024 19:14:23 +0800 Subject: [PATCH] Ameerul / P2PS-1732 / Refactor FileUploaderComponent (#10650) * chore: migrate FileUploaderComponent to typescript * fix: failing test cases * chore: added suggestions * chore: moved file-dropzone to p2p package * chore: moved truncateFileName from shared to utils file and added test cases * fix: regex potential DDos issue * chore: added suggestions * Update packages/p2p/src/components/file-dropzone/file-dropzone.tsx Co-authored-by: Niloofar Sadeghi <93518187+niloofar-deriv@users.noreply.github.com> * fix: import for types from file-dropzone * fix: flickering Icon on tab switch --------- Co-authored-by: Niloofar Sadeghi <93518187+niloofar-deriv@users.noreply.github.com> --- packages/p2p/package.json | 1 + .../fade-in-message/fade-in-message.scss | 30 +++++ .../fade-in-message/fade-in-message.tsx | 46 +++++++ .../file-dropzone/fade-in-message/index.ts | 4 + .../file-dropzone/file-dropzone.scss | 74 ++++++++++ .../file-dropzone/file-dropzone.tsx | 126 ++++++++++++++++++ .../p2p/src/components/file-dropzone/index.ts | 4 + .../__tests__/preview-single.spec.tsx | 30 +++++ .../file-dropzone/preview-single/index.ts | 4 + .../preview-single/preview-single.scss | 11 ++ .../preview-single/preview-single.tsx | 32 +++++ .../file-uploader-component.spec.tsx | 98 ++++++++------ .../file-uploader-component.tsx | 74 ++++------ .../order-details-confirm-modal.spec.tsx | 17 ++- .../order-details-confirm-modal.tsx | 22 ++- packages/p2p/src/types/file-dropzone.types.ts | 14 ++ packages/p2p/src/types/index.ts | 1 + .../src/utils/__tests__/file-uploader.spec.ts | 79 +++++++++++ packages/p2p/src/utils/file-uploader.js | 33 ----- packages/p2p/src/utils/file-uploader.ts | 92 +++++++++++++ packages/p2p/tsconfig.json | 1 - 21 files changed, 659 insertions(+), 134 deletions(-) create mode 100644 packages/p2p/src/components/file-dropzone/fade-in-message/fade-in-message.scss create mode 100644 packages/p2p/src/components/file-dropzone/fade-in-message/fade-in-message.tsx create mode 100644 packages/p2p/src/components/file-dropzone/fade-in-message/index.ts create mode 100644 packages/p2p/src/components/file-dropzone/file-dropzone.scss create mode 100644 packages/p2p/src/components/file-dropzone/file-dropzone.tsx create mode 100644 packages/p2p/src/components/file-dropzone/index.ts create mode 100644 packages/p2p/src/components/file-dropzone/preview-single/__tests__/preview-single.spec.tsx create mode 100644 packages/p2p/src/components/file-dropzone/preview-single/index.ts create mode 100644 packages/p2p/src/components/file-dropzone/preview-single/preview-single.scss create mode 100644 packages/p2p/src/components/file-dropzone/preview-single/preview-single.tsx create mode 100644 packages/p2p/src/types/file-dropzone.types.ts create mode 100644 packages/p2p/src/utils/__tests__/file-uploader.spec.ts delete mode 100644 packages/p2p/src/utils/file-uploader.js create mode 100644 packages/p2p/src/utils/file-uploader.ts diff --git a/packages/p2p/package.json b/packages/p2p/package.json index 9ec21eaa8c1e..f1bbd9578488 100644 --- a/packages/p2p/package.json +++ b/packages/p2p/package.json @@ -55,6 +55,7 @@ "react": "^17.0.2", "react-content-loader": "^6.2.0", "react-dom": "^17.0.2", + "react-dropzone": "11.0.1", "react-i18next": "^11.11.0", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", diff --git a/packages/p2p/src/components/file-dropzone/fade-in-message/fade-in-message.scss b/packages/p2p/src/components/file-dropzone/fade-in-message/fade-in-message.scss new file mode 100644 index 000000000000..b12ae7a64708 --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/fade-in-message/fade-in-message.scss @@ -0,0 +1,30 @@ +@import '../file-dropzone.scss'; + +.fade-in-message { + @include file-dropzone-message; + + &--enter-done { + opacity: 1; + transform: translate3d(0, 0, 0); + } + + &--enter { + opacity: 0; + transform: translate3d(0, -16px, 0); + } + + &--enter-active { + opacity: 1; + transform: translate3d(0, 0, 0); + } + + &--exit { + opacity: 1; + transform: translate3d(0, 0, 0); + } + + &--exit-active { + opacity: 0; + transform: translate3d(0, -16px, 0); + } +} diff --git a/packages/p2p/src/components/file-dropzone/fade-in-message/fade-in-message.tsx b/packages/p2p/src/components/file-dropzone/fade-in-message/fade-in-message.tsx new file mode 100644 index 000000000000..bcaa99259baf --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/fade-in-message/fade-in-message.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { CSSTransition } from 'react-transition-group'; +import { Text } from '@deriv/components'; + +type TFadeInMessage = { + is_visible: boolean; + color?: string; + key?: string; + timeout: number; + no_text?: boolean; +}; + +const FadeInMessage = ({ + children, + color, + is_visible, + key, + no_text, + timeout, +}: React.PropsWithChildren) => ( + + {no_text ? ( +
{children}
+ ) : ( + + {children} + + )} +
+); + +export default FadeInMessage; diff --git a/packages/p2p/src/components/file-dropzone/fade-in-message/index.ts b/packages/p2p/src/components/file-dropzone/fade-in-message/index.ts new file mode 100644 index 000000000000..dfeceae8c7b5 --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/fade-in-message/index.ts @@ -0,0 +1,4 @@ +import FadeInMessage from './fade-in-message'; +import './fade-in-message.scss'; + +export default FadeInMessage; diff --git a/packages/p2p/src/components/file-dropzone/file-dropzone.scss b/packages/p2p/src/components/file-dropzone/file-dropzone.scss new file mode 100644 index 000000000000..20f32aec31a9 --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/file-dropzone.scss @@ -0,0 +1,74 @@ +@mixin file-dropzone-message { + display: block; + max-width: 168px; + opacity: 1; + pointer-events: none; + position: absolute; + transform: translate3d(0, 0, 0); + transition: transform 0.25s ease, opacity 0.15s linear; + + @include mobile { + max-width: 26rem; + } +} + +.file-dropzone { + border-radius: $BORDER_RADIUS; + border: 1px dashed var(--border-normal); + color: var(--text-prominent); + cursor: pointer; + font-size: 1.25rem; + font-weight: bold; + height: 14rem; + padding: 2rem; + position: relative; + text-align: center; + width: 100%; + + &__content { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + left: 0; + position: absolute; + top: 0; + width: 100%; + } + + &__filename { + width: 100%; + } + + &__message { + @include file-dropzone-message; + } + + &--has-file { + border-style: solid; + border-color: var(--status-success); + background-color: var(--general-section-1); + } + + &--has-error { + border-style: solid; + border-color: var(--status-danger); + + .dc-file-dropzone__filename { + margin-top: -3em; + } + } + + &--is-noclick { + cursor: auto; + } + + &:hover, + &:focus { + outline: 0; + } + + &:hover { + background-color: rgba(0, 0, 0, 0.025); + } +} diff --git a/packages/p2p/src/components/file-dropzone/file-dropzone.tsx b/packages/p2p/src/components/file-dropzone/file-dropzone.tsx new file mode 100644 index 000000000000..bda3b5860ee2 --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/file-dropzone.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import classNames from 'classnames'; +import Dropzone, { DropzoneRef } from 'react-dropzone'; +import { Text } from '@deriv/components'; +import { TFileDropzone } from 'Types'; +import { truncateFileName } from 'Utils/file-uploader'; +import FadeInMessage from './fade-in-message'; +import PreviewSingle from './preview-single'; + +const FileDropzone = ({ className, noClick = false, ...props }: TFileDropzone) => { + const { + accept, + error_message, + filename_limit, + hover_message, + max_size, + message, + multiple, + onDropAccepted, + onDropRejected, + validation_error_message, + value, + } = props; + + const RenderErrorMessage = React.useCallback( + ({ open }: DropzoneRef) => { + if (noClick && typeof message === 'function') return <>{message(open)}; + + return <>{message}; + }, + [message, noClick] + ); + + const RenderValidationErrorMessage = React.useCallback( + ({ open }: DropzoneRef) => { + if (typeof validation_error_message === 'function') return <>{validation_error_message(open)}; + + return <>{validation_error_message}; + }, + [validation_error_message] + ); + + const dropzone_ref = React.useRef(null); + + return ( + + {({ getRootProps, getInputProps, isDragAccept, isDragActive, isDragReject, open }) => ( +
0, + 'file-dropzone--has-error': (isDragReject || !!validation_error_message) && !isDragAccept, + 'file-dropzone--is-noclick': noClick, + })} + ref={dropzone_ref} + > + +
+ + + + + {hover_message} + + {/* Handle cases for displaying multiple files and single filenames */} + {multiple && value.length > 0 && !validation_error_message + ? value.map((file, idx) => ( + + {filename_limit ? truncateFileName(file, filename_limit) : file.name} + + )) + : value[0] && + !isDragActive && + !validation_error_message && } + + {error_message} + + + + +
+
+ )} +
+ ); +}; + +export default FileDropzone; diff --git a/packages/p2p/src/components/file-dropzone/index.ts b/packages/p2p/src/components/file-dropzone/index.ts new file mode 100644 index 000000000000..a9975d4eb6e0 --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/index.ts @@ -0,0 +1,4 @@ +import FileDropzoneComponent from './file-dropzone'; +import './file-dropzone.scss'; + +export default FileDropzoneComponent; diff --git a/packages/p2p/src/components/file-dropzone/preview-single/__tests__/preview-single.spec.tsx b/packages/p2p/src/components/file-dropzone/preview-single/__tests__/preview-single.spec.tsx new file mode 100644 index 000000000000..d554417286a5 --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/preview-single/__tests__/preview-single.spec.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import PreviewSingle from '../preview-single'; + +describe('', () => { + const props = { + dropzone_ref: React.createRef(), + error_message: '', + hover_message: '', + onClickClose: jest.fn(), + value: [] as File[], + }; + + it('should render the Text component if preview_single is false', () => { + const file: File = new File(['hello'], 'hello.png', { type: 'image/png' }); + props.value = [file]; + + render(); + + expect(screen.getByText('hello.png')).toBeInTheDocument(); + }); + + it('should render the Image component if preview_single is true', () => { + const preview_single = ; + + render(); + + expect(screen.getByTestId('dt_image')).toBeInTheDocument(); + }); +}); diff --git a/packages/p2p/src/components/file-dropzone/preview-single/index.ts b/packages/p2p/src/components/file-dropzone/preview-single/index.ts new file mode 100644 index 000000000000..8d5ce814df48 --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/preview-single/index.ts @@ -0,0 +1,4 @@ +import PreviewSingle from './preview-single'; +import './preview-single.scss'; + +export default PreviewSingle; diff --git a/packages/p2p/src/components/file-dropzone/preview-single/preview-single.scss b/packages/p2p/src/components/file-dropzone/preview-single/preview-single.scss new file mode 100644 index 000000000000..617f3181570d --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/preview-single/preview-single.scss @@ -0,0 +1,11 @@ +@import '../file-dropzone.scss'; + +.preview-single { + &__filename { + width: 100%; + } + + &__message { + @include file-dropzone-message; + } +} diff --git a/packages/p2p/src/components/file-dropzone/preview-single/preview-single.tsx b/packages/p2p/src/components/file-dropzone/preview-single/preview-single.tsx new file mode 100644 index 000000000000..25d8dd21d2bd --- /dev/null +++ b/packages/p2p/src/components/file-dropzone/preview-single/preview-single.tsx @@ -0,0 +1,32 @@ +import React, { RefObject } from 'react'; +import { Text } from '@deriv/components'; +import { TFileDropzone } from 'Types'; +import { truncateFileName } from 'Utils/file-uploader'; + +type TPreviewSingle = { + dropzone_ref: RefObject; +} & TFileDropzone; + +const PreviewSingle = (props: TPreviewSingle) => { + const { dropzone_ref, filename_limit, preview_single, value } = props; + + if (preview_single) { + return
{preview_single}
; + } + + return ( + + {filename_limit ? truncateFileName(value[0], filename_limit) : value[0].name} + + ); +}; + +export default PreviewSingle; diff --git a/packages/p2p/src/components/file-uploader-component/__tests__/file-uploader-component.spec.tsx b/packages/p2p/src/components/file-uploader-component/__tests__/file-uploader-component.spec.tsx index 5b5226c27299..f5b19da93231 100644 --- a/packages/p2p/src/components/file-uploader-component/__tests__/file-uploader-component.spec.tsx +++ b/packages/p2p/src/components/file-uploader-component/__tests__/file-uploader-component.spec.tsx @@ -1,57 +1,63 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { StoreProvider, mockStore } from '@deriv/stores'; +import { mockStore, StoreProvider } from '@deriv/stores'; import FileUploaderComponent from '../file-uploader-component'; -describe('', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); +const wrapper = ({ children }: { children: JSX.Element }) => ( + {children} +); + +jest.mock('@deriv/shared', () => ({ + ...jest.requireActual('@deriv/shared'), + compressImageFiles: jest.fn(() => Promise.resolve([{ path: 'hello.pdf' }])), + readFiles: jest.fn(), +})); - const file = new File(['hello'], 'hello.png', { type: 'image/png' }); +jest.mock('@binary-com/binary-document-uploader'); + +describe('', () => { + const file: File = new File(['hello'], 'hello.png', { type: 'image/png' }); const props = { accept: 'image/pdf, image/png', - filename_limit: 26, - hover_message: 'drop here', + hover_message: 'hover here', max_size: 2097152, multiple: false, - setDocumentFile: jest.fn(), - validation_error_message: null, + onDropAccepted: jest.fn(), + onDropRejected: jest.fn(), + validation_error_message: '', + onClickClose: jest.fn(), upload_message: 'upload here', - value: [], + value: [] as File[], }; it('should render FileUploaderComponent component in desktop mode', async () => { - render(, { - wrapper: ({ children }) => {children}, - }); + render(, { wrapper }); expect(screen.getByText('upload here')).toBeInTheDocument(); }); it('should upload supported file', async () => { - const new_props = { - ...props, - value: [file], - }; - render(, { - wrapper: ({ children }) => {children}, - }); - const input: HTMLInputElement = screen.getByTestId('dt_file_upload_input'); + props.value = [file]; + + render(, { wrapper }); + + const input = screen.getByTestId('dt_file_upload_input') as HTMLInputElement; userEvent.upload(input, file); + await waitFor(() => { - expect(input.files?.[0]).toBe(file); - expect(input.files).toHaveLength(1); + if (input.files) { + expect(input.files[0]).toBe(file); + expect(input.files).toHaveLength(1); + } }); - expect(props.setDocumentFile).toHaveBeenCalledWith({ files: [file], error_message: null }); + expect(screen.getByText('hello.png')).toBeInTheDocument(); }); - it('should show error message when unsupported file is uploaded', async () => { - const new_props = { ...props, validation_error_message: 'error' }; - render(, { - wrapper: ({ children }) => {children}, - }); + it('should show validation_error_message when unsupported file is uploaded', async () => { + props.validation_error_message = 'error'; + + render(, { wrapper }); const unsupported_file = new File(['hello'], 'hello.html', { type: 'html' }); const input = screen.getByTestId('dt_file_upload_input'); @@ -61,21 +67,29 @@ describe('', () => { expect(screen.getByText('error')).toBeInTheDocument(); }); }); - it('should handle remove File', async () => { - const new_props = { ...props, validation_error_message: 'error' }; - render(, { - wrapper: ({ children }) => {children}, - }); - const input: HTMLInputElement = screen.getByTestId('dt_file_upload_input'); + it('should render validation error message if validation_error_message is passed as a function', () => { + props.validation_error_message = () => 'error'; + + render(, { wrapper }); + + expect(screen.getByText('error')).toBeInTheDocument(); + }); + + it('should return multiple files and single filenames if multiple is true, values > 0 and validation_error_message is empty', async () => { + const file_bye: File = new File(['bye'], 'bye.png', { type: 'image/png' }); + props.multiple = true; + props.value = [file, file_bye]; + props.validation_error_message = ''; + + render(, { wrapper }); + + const input = screen.getByTestId('dt_file_upload_input') as HTMLInputElement; userEvent.upload(input, file); + await waitFor(() => { - expect(input.files?.[0]).toBe(file); - expect(input.files).toHaveLength(1); + expect(screen.getByText('hello.png')).toBeInTheDocument(); + expect(screen.getByText('bye.png')).toBeInTheDocument(); }); - const remove_icon = screen.getByTestId('dt_remove_file_icon'); - expect(remove_icon).toBeInTheDocument(); - userEvent.click(remove_icon); - expect(props.setDocumentFile).toHaveBeenCalledWith({ files: [], error_message: null }); }); }); diff --git a/packages/p2p/src/components/file-uploader-component/file-uploader-component.tsx b/packages/p2p/src/components/file-uploader-component/file-uploader-component.tsx index adb82202f5e6..c1554c981eb5 100644 --- a/packages/p2p/src/components/file-uploader-component/file-uploader-component.tsx +++ b/packages/p2p/src/components/file-uploader-component/file-uploader-component.tsx @@ -1,41 +1,20 @@ import React from 'react'; -import { FileDropzone, Icon, Text } from '@deriv/components'; -import { TFile } from '@deriv/shared'; +import { Icon, Text } from '@deriv/components'; import { useStore } from '@deriv/stores'; import { localize } from 'Components/i18next'; -import { getErrorMessage } from 'Utils/file-uploader'; - -type TDocumentFile = { - files: TFile[]; - error_message: string | null; -}; +import FileDropzone from 'Components/file-dropzone'; type TFileUploaderComponentProps = { accept: string; hover_message: string; max_size: number; multiple?: boolean; - setDocumentFile: React.Dispatch>; + onClickClose: () => void; + onDropAccepted: (files: File[]) => void; + onDropRejected: (files: File[]) => void; upload_message: string; validation_error_message: string | null; - value: File[]; -}; - -type TUploadMessageProps = { - upload_message: string; -}; - -const UploadMessage = ({ upload_message }: TUploadMessageProps) => { - const { ui } = useStore(); - const { is_mobile } = ui; - return ( - - - - {upload_message} - - - ); + value: (File & { file: Blob })[]; }; const FileUploaderComponent = ({ @@ -43,24 +22,27 @@ const FileUploaderComponent = ({ hover_message, max_size, multiple = false, - setDocumentFile, + onClickClose, + onDropAccepted, + onDropRejected, upload_message, validation_error_message, value, }: TFileUploaderComponentProps) => { - const handleAcceptedFiles = (files: TFile[]) => { - if (files.length > 0) { - setDocumentFile({ files, error_message: null }); - } - }; - - const removeFile = () => { - setDocumentFile({ files: [], error_message: null }); - }; + const { + ui: { is_mobile }, + } = useStore(); - const handleRejectedFiles = (files: TFile[]) => { - setDocumentFile({ files, error_message: getErrorMessage(files) }); - }; + const getUploadMessage = React.useCallback(() => { + return ( + <> + + + {upload_message} + + + ); + }, [is_mobile, upload_message]); return (
@@ -70,24 +52,24 @@ const FileUploaderComponent = ({ filename_limit={26} hover_message={hover_message} max_size={max_size} - message={} + message={getUploadMessage()} multiple={multiple} - onDropAccepted={handleAcceptedFiles} - onDropRejected={handleRejectedFiles} + onDropAccepted={onDropAccepted} + onDropRejected={onDropRejected} validation_error_message={validation_error_message} value={value} /> {(value.length > 0 || !!validation_error_message) && ( )}
); }; -export default FileUploaderComponent; +export default React.memo(FileUploaderComponent); diff --git a/packages/p2p/src/components/modal-manager/modals/order-details-confirm-modal/__tests__/order-details-confirm-modal.spec.tsx b/packages/p2p/src/components/modal-manager/modals/order-details-confirm-modal/__tests__/order-details-confirm-modal.spec.tsx index 1c24ef20a29a..f8b074111aef 100644 --- a/packages/p2p/src/components/modal-manager/modals/order-details-confirm-modal/__tests__/order-details-confirm-modal.spec.tsx +++ b/packages/p2p/src/components/modal-manager/modals/order-details-confirm-modal/__tests__/order-details-confirm-modal.spec.tsx @@ -8,6 +8,10 @@ import OrderDetailsConfirmModal from '../order-details-confirm-modal'; const el_modal = document.createElement('div'); +const wrapper = ({ children }: React.PropsWithChildren) => ( + {children} +); + jest.mock('Utils/websocket', () => ({ ...jest.requireActual('Utils/websocket'), requestWS: jest.fn().mockResolvedValue({ error: { message: 'P2P Error' } }), @@ -53,9 +57,7 @@ describe('', () => { }); it('should render the modal', () => { - render(, { - wrapper: ({ children }) => {children}, - }); + render(, { wrapper }); expect(screen.getByText('Payment confirmation')).toBeInTheDocument(); expect( @@ -70,9 +72,7 @@ describe('', () => { it('should handle GoBack Click', () => { const { hideModal } = useModalManagerContext(); - render(, { - wrapper: ({ children }) => {children}, - }); + render(, { wrapper }); const cancel_button = screen.getByRole('button', { name: 'Go Back' }); expect(cancel_button).toBeInTheDocument(); @@ -84,9 +84,8 @@ describe('', () => { const { hideModal } = useModalManagerContext(); const { confirmOrderRequest, order_information } = order_store; - render(, { - wrapper: ({ children }) => {children}, - }); + render(, { wrapper }); + const file = new File(['hello'], 'hello.png', { type: 'image/png' }); const input: HTMLInputElement = screen.getByTestId('dt_file_upload_input'); userEvent.upload(input, file); diff --git a/packages/p2p/src/components/modal-manager/modals/order-details-confirm-modal/order-details-confirm-modal.tsx b/packages/p2p/src/components/modal-manager/modals/order-details-confirm-modal/order-details-confirm-modal.tsx index aebef155c968..9ef22ef42424 100644 --- a/packages/p2p/src/components/modal-manager/modals/order-details-confirm-modal/order-details-confirm-modal.tsx +++ b/packages/p2p/src/components/modal-manager/modals/order-details-confirm-modal/order-details-confirm-modal.tsx @@ -7,7 +7,7 @@ import FileUploaderComponent from 'Components/file-uploader-component'; import { Localize, localize } from 'Components/i18next'; import { useModalManagerContext } from 'Components/modal-manager/modal-manager-context'; import { useStores } from 'Stores'; -import { accepted_file_types, max_pot_file_size } from 'Utils/file-uploader'; +import { getErrorMessage, max_pot_file_size } from 'Utils/file-uploader'; import { removeTrailingZeros, roundOffDecimal, setDecimalPlaces } from 'Utils/format-value'; type TDocumentFile = { @@ -26,6 +26,20 @@ const OrderDetailsConfirmModal = () => { const { amount_display, local_currency, other_user_details, rate, id } = order_information ?? {}; const [document_file, setDocumentFile] = React.useState({ files: [], error_message: null }); + const handleAcceptedFiles = (files: TFile[]) => { + if (files.length > 0) { + setDocumentFile({ files, error_message: null }); + } + }; + + const removeFile = () => { + setDocumentFile({ files: [], error_message: null }); + }; + + const handleRejectedFiles = (files: TFile[]) => { + setDocumentFile({ files, error_message: getErrorMessage(files) }); + }; + const display_payment_amount = removeTrailingZeros( formatMoney(local_currency, amount_display * Number(roundOffDecimal(rate, setDecimalPlaces(rate, 6))), true) ); @@ -69,10 +83,12 @@ const OrderDetailsConfirmModal = () => { void) => ReactNode); + preview_single?: ReactElement; + validation_error_message?: ReactNode | ((open?: () => void) => ReactNode); + value: Array; +} & DropzoneOptions; diff --git a/packages/p2p/src/types/index.ts b/packages/p2p/src/types/index.ts index c71c62373917..8dcb7a090f2e 100755 --- a/packages/p2p/src/types/index.ts +++ b/packages/p2p/src/types/index.ts @@ -1,3 +1,4 @@ export * from './adverts.types'; +export * from './file-dropzone.types'; export * from './modal-manager.types'; export * from './my-profile.types'; diff --git a/packages/p2p/src/utils/__tests__/file-uploader.spec.ts b/packages/p2p/src/utils/__tests__/file-uploader.spec.ts new file mode 100644 index 000000000000..ecbdfcc12ea8 --- /dev/null +++ b/packages/p2p/src/utils/__tests__/file-uploader.spec.ts @@ -0,0 +1,79 @@ +import { + TFile, + convertToMB, + getErrorMessage, + getPotSupportedFiles, + isImageType, + isPDFType, + truncateFileName, +} from 'Utils/file-uploader'; + +describe('convertToMB', () => { + it('should convert bytes to MB', () => { + expect(convertToMB(1024 * 1024)).toEqual(1); + }); +}); + +describe('getErrorMessage', () => { + it('should return error message if file is too large', () => { + const file = { + file: { + name: 'test.pdf', + size: 1024 * 1024 * 6, + }, + }; + expect(getErrorMessage([file as TFile])).toEqual('Cannot upload a file over 5MB'); + }); + + it('should return error message if file is not supported', () => { + const file = { + file: { + name: 'test.txt', + size: 1024 * 1024 * 4, + }, + }; + expect(getErrorMessage([file as TFile])).toEqual('The file you uploaded is not supported. Upload another.'); + }); +}); + +describe('getPotSupportedFiles', () => { + it('should return true if file is supported', () => { + expect(getPotSupportedFiles('test.pdf')).toEqual(true); + }); + + it('should return false if file is not supported', () => { + expect(getPotSupportedFiles('test.txt')).toEqual(false); + }); +}); + +describe('isImageType', () => { + it('should return true if file is an image', () => { + expect(isImageType('image/jpeg')).toEqual(true); + }); + + it('should return false if file is not an image', () => { + expect(isImageType('application/pdf')).toEqual(false); + }); +}); + +describe('isPDFType', () => { + it('should return true if file is a pdf', () => { + expect(isPDFType('application/pdf')).toEqual(true); + }); + + it('should return false if file is not a pdf', () => { + expect(isPDFType('image/jpeg')).toEqual(false); + }); +}); + +describe('truncateFileName', () => { + const file = { + name: 'example_file_name_that_is_longer_than_30_characters.jpg', + type: 'image/jpeg', + file: new Blob(), + }; + + it('should truncate file name if it is too long', () => { + expect(truncateFileName(file as TFile, 30)).toEqual('example_file_name_that_is_long….jpeg'); + }); +}); diff --git a/packages/p2p/src/utils/file-uploader.js b/packages/p2p/src/utils/file-uploader.js deleted file mode 100644 index c8063665532f..000000000000 --- a/packages/p2p/src/utils/file-uploader.js +++ /dev/null @@ -1,33 +0,0 @@ -import { localize } from 'Components/i18next'; - -export const accepted_file_types = 'image/png, image/jpeg, image/jpg, application/pdf'; - -export const convertToMB = bytes => bytes / (1024 * 1024); - -export const getPotSupportedFiles = filename => /^.*\.(png|PNG|jpg|JPG|jpeg|JPEG|pdf|PDF)$/.test(filename); - -export const max_pot_file_size = 5242880; - -export const isImageType = type => ['image/jpeg', 'image/png', 'image/gif'].includes(type); - -export const isPDFType = type => type === 'application/pdf'; - -const isFileTooLarge = files => files?.length > 0 && files[0].file.size > max_pot_file_size; -const isFileSupported = files => files.filter(each_file => getPotSupportedFiles(each_file.file.name))?.length > 0; - -export const getErrorMessage = files => - isFileTooLarge(files) && isFileSupported(files) - ? localize('Cannot upload a file over 5MB') - : localize('The file you uploaded is not supported. Upload another.'); - -/** - * The function renames the files by removing any non ISO-8859-1 code point from filename and returns a new blob object with the updated file name. - * @param {File} file - * @returns {Blob} - */ -export const renameFile = file => { - const new_file = new Blob([file], { type: file.type }); - // eslint-disable-next-line no-control-regex - new_file.name = file.name.replace(/[^\x00-\x7F]+/g, ''); - return new_file; -}; diff --git a/packages/p2p/src/utils/file-uploader.ts b/packages/p2p/src/utils/file-uploader.ts new file mode 100644 index 000000000000..24d1b372d123 --- /dev/null +++ b/packages/p2p/src/utils/file-uploader.ts @@ -0,0 +1,92 @@ +import { localize } from 'Components/i18next'; + +export type TFile = File & { file: Blob }; + +export const max_pot_file_size = 5242880; + +/** + * Convert bytes to MB + * @param {number} bytes + * @returns {number} MB + */ +export const convertToMB = (bytes: number): number => bytes / (1024 * 1024); + +/** + * Gets the supported file extensions from the filename + * @param {string} filename + * @returns {boolean} true if supported, false otherwise + */ +export const getPotSupportedFiles = (filename: string): boolean => + /^.*\.(png|PNG|jpg|JPG|jpeg|JPEG|pdf|PDF)$/.test(filename); + +/** + * Checks if the file type is an image + * @param {string} type + * @returns {boolean} true if image, false otherwise + */ +export const isImageType = (type: string): boolean => ['image/jpeg', 'image/png', 'image/gif'].includes(type); + +/** + * Checks if the file type is a pdf + * @param {string} type + * @returns {boolean} true if pdf, false otherwise + */ +export const isPDFType = (type: string): boolean => type === 'application/pdf'; + +/** + * Checks if the file is too large + * @param {TFile[]} files + * @returns {boolean} true if file is too large, false otherwise + */ +const isFileTooLarge = (files: TFile[]): boolean => files?.length > 0 && files[0].file.size > max_pot_file_size; + +/** + * Checks if the file is supported + * @param {TFile[]} files + * @returns {boolean} true if file is supported, false otherwise + */ +const isFileSupported = (files: TFile[]): boolean => + files.filter(each_file => getPotSupportedFiles(each_file.file.name))?.length > 0; + +/** + * Gets the error message for the file if it is not supported or too large + * @param {TFile[]} files + * @returns {string} error message + */ +export const getErrorMessage = (files: TFile[]): string => + isFileTooLarge(files) && isFileSupported(files) + ? localize('Cannot upload a file over 5MB') + : localize('The file you uploaded is not supported. Upload another.'); + +/** + * Truncates the file name to a certain length + * @param {TFile} file + * @param {number} limit + * @returns {string} truncated file name + */ +export const truncateFileName = (file: TFile, limit: number): string => { + const string_limit_regex = new RegExp(`(.{${limit || 30}})..+`); + return file?.name?.replace(string_limit_regex, `$1….${getFileExtension(file)}`); +}; + +/** + * Gets the file extension + * @param {TFile} file + * @returns {string | null} file extension or null if not found + */ +const getFileExtension = (file: TFile): string | null => { + const f = file?.type?.match(/[^/]+$/u); + return f && f[0]; +}; + +/** + * The function renames the files by removing any non ISO-8859-1 code point from filename and returns a new blob object with the updated file name. + * @param {TFile} file + * @returns {Blob} + */ +export const renameFile = (file: TFile): Blob => { + const new_file = new Blob([file], { type: file.type }); + // eslint-disable-next-line no-control-regex + new_file.name = file.name.replace(/[^\x00-\x7F]+/g, ''); + return new_file; +}; diff --git a/packages/p2p/tsconfig.json b/packages/p2p/tsconfig.json index 4fb643c3ecb6..275564ad02b0 100644 --- a/packages/p2p/tsconfig.json +++ b/packages/p2p/tsconfig.json @@ -11,7 +11,6 @@ "Stores/*": ["src/stores/*"], "Translations/*": ["src/translations/*"], "Types": ["src/types"], - "Types/*": ["src/types/*"], "Utils/*": ["src/utils/*"], "@deriv/*": ["../*/src"] },