From eb12fa5dd59f0edb8c90815f6c08017f39dc537d Mon Sep 17 00:00:00 2001 From: Yauheni Pasiukevich Date: Fri, 18 Oct 2024 20:12:01 +0200 Subject: [PATCH 01/11] feat: create upload picker component --- src/CONST.ts | 5 + .../AttachmentPicker/index.native.tsx | 182 +++++++++++------- src/components/AttachmentPicker/index.tsx | 41 +++- src/components/AttachmentPicker/types.ts | 8 +- src/components/AvatarWithImagePicker.tsx | 8 +- src/components/Form/types.ts | 8 +- .../ImportOnyxState/BaseImportOnyxState.tsx | 2 +- src/components/UploadFile.tsx | 113 +++++++++++ src/languages/en.ts | 4 + src/languages/es.ts | 4 + src/languages/params.ts | 5 + .../AttachmentPickerWithMenuItems.tsx | 2 +- .../step/IOURequestStepScan/index.native.tsx | 2 +- .../request/step/IOURequestStepScan/index.tsx | 4 +- src/styles/variables.ts | 1 + 15 files changed, 299 insertions(+), 90 deletions(-) create mode 100644 src/components/UploadFile.tsx diff --git a/src/CONST.ts b/src/CONST.ts index 399535412f0e..88075d6f6aa8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -478,6 +478,11 @@ const CONST = { PERSONAL: 'PERSONAL', }, }, + NON_USD_BANK_ACCOUNT: { + ALLOWED_FILE_TYPES: ['pdf', 'jpg', 'jpeg', 'png'], + FILE_LIMIT: 10, + TOTAL_FILES_SIZE_LIMIT_IN_MB: 5, + }, INCORPORATION_TYPES: { LLC: 'LLC', CORPORATION: 'Corp', diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 975ea6c548c0..6b63888609e2 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -33,46 +33,46 @@ type Item = { pickAttachment: () => Promise; }; -/** - * See https://github.com/react-native-image-picker/react-native-image-picker/#options - * for ImagePicker configuration options - */ -const imagePickerOptions: Partial = { - includeBase64: false, - saveToPhotos: false, - selectionLimit: 1, - includeExtra: false, - assetRepresentationMode: 'current', -}; - /** * Return imagePickerOptions based on the type */ -const getImagePickerOptions = (type: string): CameraOptions => { +const getImagePickerOptions = (type: string, fileLimit: number): CameraOptions | ImageLibraryOptions => { // mediaType property is one of the ImagePicker configuration to restrict types' const mediaType = type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? 'photo' : 'mixed'; + + /** + * See https://github.com/react-native-image-picker/react-native-image-picker/#options + * for ImagePicker configuration options + */ return { mediaType, - ...imagePickerOptions, + includeBase64: false, + saveToPhotos: false, + includeExtra: false, + assetRepresentationMode: 'current', + selectionLimit: fileLimit, }; }; /** * Return documentPickerOptions based on the type * @param {String} type + * @param {Number} fileLimit * @returns {Object} */ -const getDocumentPickerOptions = (type: string): DocumentPickerOptions => { +const getDocumentPickerOptions = (type: string, fileLimit: number): DocumentPickerOptions => { if (type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE) { return { type: [RNDocumentPicker.types.images], copyTo: 'cachesDirectory', + allowMultiSelection: fileLimit !== 1, }; } return { type: [RNDocumentPicker.types.allFiles], copyTo: 'cachesDirectory', + allowMultiSelection: fileLimit !== 1, }; }; @@ -111,16 +111,19 @@ function AttachmentPicker({ type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false, - shouldHideGalleryOption = false, shouldValidateImage = true, + shouldHideGalleryOption = false, + fileLimit = 1, + totalFilesSizeLimitInMB = 0, }: AttachmentPickerProps) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); - const completeAttachmentSelection = useRef<(data: FileObject) => void>(() => {}); + const completeAttachmentSelection = useRef<(data: FileObject[]) => void>(() => {}); const onModalHide = useRef<() => void>(); const onCanceled = useRef<() => void>(() => {}); const popoverRef = useRef(null); + const totalFilesSizeLimitInBytes = totalFilesSizeLimitInMB * 1024 * 1024; const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -135,6 +138,13 @@ function AttachmentPicker({ [translate], ); + const showFilesTooBigAlert = useCallback( + (message = translate('attachmentPicker.filesTooBig')) => { + Alert.alert(translate('attachmentPicker.filesTooBigMessage'), message); + }, + [translate], + ); + /** * Common image picker handling * @@ -143,7 +153,7 @@ function AttachmentPicker({ const showImagePicker = useCallback( (imagePickerFunc: (options: CameraOptions, callback: Callback) => Promise): Promise => new Promise((resolve, reject) => { - imagePickerFunc(getImagePickerOptions(type), (response: ImagePickerResponse) => { + imagePickerFunc(getImagePickerOptions(type, fileLimit), (response: ImagePickerResponse) => { if (response.didCancel) { // When the user cancelled resolve with no attachment return resolve(); @@ -200,7 +210,7 @@ function AttachmentPicker({ } }); }), - [showGeneralAlert, type], + [fileLimit, showGeneralAlert, type], ); /** * Launch the DocumentPicker. Results are in the same format as ImagePicker @@ -209,7 +219,7 @@ function AttachmentPicker({ */ const showDocumentPicker = useCallback( (): Promise => - RNDocumentPicker.pick(getDocumentPickerOptions(type)).catch((error: Error) => { + RNDocumentPicker.pick(getDocumentPickerOptions(type, fileLimit)).catch((error: Error) => { if (RNDocumentPicker.isCancel(error)) { return; } @@ -217,7 +227,7 @@ function AttachmentPicker({ showGeneralAlert(error.message); throw error; }), - [showGeneralAlert, type], + [fileLimit, showGeneralAlert, type], ); const menuItemData: Item[] = useMemo(() => { @@ -261,7 +271,7 @@ function AttachmentPicker({ * @param onPickedHandler A callback that will be called with the selected attachment * @param onCanceledHandler A callback that will be called without a selected attachment */ - const open = (onPickedHandler: (file: FileObject) => void, onCanceledHandler: () => void = () => {}) => { + const open = (onPickedHandler: (files: FileObject[]) => void, onCanceledHandler: () => void = () => {}) => { // eslint-disable-next-line react-compiler/react-compiler completeAttachmentSelection.current = onPickedHandler; onCanceled.current = onCanceledHandler; @@ -286,7 +296,7 @@ function AttachmentPicker({ } return getDataForUpload(fileData) .then((result) => { - completeAttachmentSelection.current(result); + completeAttachmentSelection.current([result]); }) .catch((error: Error) => { showGeneralAlert(error.message); @@ -301,63 +311,91 @@ function AttachmentPicker({ * sends the selected attachment to the caller (parent component) */ const pickAttachment = useCallback( - (attachments: Asset[] | DocumentPickerResponse[] | void = []): Promise | undefined => { + (attachments: Asset[] | DocumentPickerResponse[] | void = []): Promise | undefined => { if (!attachments || attachments.length === 0) { onCanceled.current(); - return Promise.resolve(); + return Promise.resolve([]); } - const fileData = attachments[0]; - if (!fileData) { - onCanceled.current(); - return Promise.resolve(); - } - /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ - const fileDataName = ('fileName' in fileData && fileData.fileName) || ('name' in fileData && fileData.name) || ''; - const fileDataUri = ('fileCopyUri' in fileData && fileData.fileCopyUri) || ('uri' in fileData && fileData.uri) || ''; - - const fileDataObject: FileResponse = { - name: fileDataName ?? '', - uri: fileDataUri, - size: ('size' in fileData && fileData.size) || ('fileSize' in fileData && fileData.fileSize) || null, - type: fileData.type ?? '', - width: ('width' in fileData && fileData.width) || undefined, - height: ('height' in fileData && fileData.height) || undefined, - }; + if (totalFilesSizeLimitInMB) { + const totalFileSize = attachments.reduce((total, fileData) => { + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + const size = ('size' in fileData && fileData.size) || ('fileSize' in fileData && fileData.fileSize) || 0; + return total + size; + }, 0); - if (!shouldValidateImage && fileDataName && Str.isImage(fileDataName)) { - ImageSize.getSize(fileDataUri) - .then(({width, height}) => { - fileDataObject.width = width; - fileDataObject.height = height; - return fileDataObject; - }) - .then((file) => { - getDataForUpload(file) - .then((result) => { - completeAttachmentSelection.current(result); - }) - .catch((error: Error) => { - showGeneralAlert(error.message); - throw error; - }); - }); - return; + if (totalFileSize > totalFilesSizeLimitInBytes) { + showFilesTooBigAlert(); + return Promise.resolve([]); + } } - /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ - if (fileDataName && Str.isImage(fileDataName)) { - ImageSize.getSize(fileDataUri) - .then(({width, height}) => { - fileDataObject.width = width; - fileDataObject.height = height; - validateAndCompleteAttachmentSelection(fileDataObject); - }) - .catch(() => showImageCorruptionAlert()); - } else { + + const filesToProcess = attachments.map((fileData) => { + if (!fileData) { + onCanceled.current(); + return Promise.resolve(); + } + + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + const fileDataName = ('fileName' in fileData && fileData.fileName) || ('name' in fileData && fileData.name) || ''; + const fileDataUri = ('fileCopyUri' in fileData && fileData.fileCopyUri) || ('uri' in fileData && fileData.uri) || ''; + + const fileDataObject: FileResponse = { + name: fileDataName ?? '', + uri: fileDataUri, + size: ('size' in fileData && fileData.size) || ('fileSize' in fileData && fileData.fileSize) || null, + type: fileData.type ?? '', + width: ('width' in fileData && fileData.width) || undefined, + height: ('height' in fileData && fileData.height) || undefined, + }; + + if (!shouldValidateImage && fileDataName && Str.isImage(fileDataName)) { + return ImageSize.getSize(fileDataUri) + .then(({width, height}) => { + fileDataObject.width = width; + fileDataObject.height = height; + return fileDataObject; + }) + .then((file) => { + return getDataForUpload(file) + .then((result) => completeAttachmentSelection.current([result])) + .catch((error) => { + if (error instanceof Error) { + showGeneralAlert(error.message); + } else { + showGeneralAlert('An unknown error occurred'); + } + throw error; + }); + }) + .catch(() => { + showImageCorruptionAlert(); + }); + } + + if (fileDataName && Str.isImage(fileDataName)) { + return ImageSize.getSize(fileDataUri) + .then(({width, height}) => { + fileDataObject.width = width; + fileDataObject.height = height; + + if (fileDataObject.width <= 0 || fileDataObject.height <= 0) { + showImageCorruptionAlert(); + return Promise.resolve(); // Skip processing this corrupted file + } + + return validateAndCompleteAttachmentSelection(fileDataObject); + }) + .catch(() => { + showImageCorruptionAlert(); + }); + } return validateAndCompleteAttachmentSelection(fileDataObject); - } + }); + + return Promise.all(filesToProcess); }, - [validateAndCompleteAttachmentSelection, showImageCorruptionAlert, shouldValidateImage, showGeneralAlert], + [totalFilesSizeLimitInMB, totalFilesSizeLimitInBytes, showFilesTooBigAlert, shouldValidateImage, validateAndCompleteAttachmentSelection, showGeneralAlert, showImageCorruptionAlert], ); /** diff --git a/src/components/AttachmentPicker/index.tsx b/src/components/AttachmentPicker/index.tsx index c4979f544080..69990e7fbf28 100644 --- a/src/components/AttachmentPicker/index.tsx +++ b/src/components/AttachmentPicker/index.tsx @@ -1,5 +1,7 @@ import React, {useRef} from 'react'; import type {ValueOf} from 'type-fest'; +import type {FileObject} from '@components/AttachmentModal'; +import useLocalize from '@hooks/useLocalize'; import * as Browser from '@libs/Browser'; import Visibility from '@libs/Visibility'; import CONST from '@src/CONST'; @@ -42,10 +44,12 @@ function getAcceptableFileTypesFromAList(fileTypes: Array