Skip to content

Commit

Permalink
feat: create upload picker component
Browse files Browse the repository at this point in the history
  • Loading branch information
pasyukevich committed Oct 18, 2024
1 parent 2c211a0 commit 6854225
Show file tree
Hide file tree
Showing 15 changed files with 299 additions and 90 deletions.
5 changes: 5 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
182 changes: 110 additions & 72 deletions src/components/AttachmentPicker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,46 +33,46 @@ type Item = {
pickAttachment: () => Promise<Asset[] | void | DocumentPickerResponse[]>;
};

/**
* See https://github.com/react-native-image-picker/react-native-image-picker/#options
* for ImagePicker configuration options
*/
const imagePickerOptions: Partial<CameraOptions | ImageLibraryOptions> = {
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,
};
};

Expand Down Expand Up @@ -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();
Expand All @@ -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
*
Expand All @@ -143,7 +153,7 @@ function AttachmentPicker({
const showImagePicker = useCallback(
(imagePickerFunc: (options: CameraOptions, callback: Callback) => Promise<ImagePickerResponse>): Promise<Asset[] | void> =>
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();
Expand Down Expand Up @@ -200,7 +210,7 @@ function AttachmentPicker({
}
});
}),
[showGeneralAlert, type],
[fileLimit, showGeneralAlert, type],
);
/**
* Launch the DocumentPicker. Results are in the same format as ImagePicker
Expand All @@ -209,15 +219,15 @@ function AttachmentPicker({
*/
const showDocumentPicker = useCallback(
(): Promise<DocumentPickerResponse[] | void> =>
RNDocumentPicker.pick(getDocumentPickerOptions(type)).catch((error: Error) => {
RNDocumentPicker.pick(getDocumentPickerOptions(type, fileLimit)).catch((error: Error) => {
if (RNDocumentPicker.isCancel(error)) {
return;
}

showGeneralAlert(error.message);
throw error;
}),
[showGeneralAlert, type],
[fileLimit, showGeneralAlert, type],
);

const menuItemData: Item[] = useMemo(() => {
Expand Down Expand Up @@ -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;
Expand All @@ -286,7 +296,7 @@ function AttachmentPicker({
}
return getDataForUpload(fileData)
.then((result) => {
completeAttachmentSelection.current(result);
completeAttachmentSelection.current([result]);
})
.catch((error: Error) => {
showGeneralAlert(error.message);
Expand All @@ -301,63 +311,91 @@ function AttachmentPicker({
* sends the selected attachment to the caller (parent component)
*/
const pickAttachment = useCallback(
(attachments: Asset[] | DocumentPickerResponse[] | void = []): Promise<void> | undefined => {
(attachments: Asset[] | DocumentPickerResponse[] | void = []): Promise<void[]> | 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],
);

/**
Expand Down
41 changes: 35 additions & 6 deletions src/components/AttachmentPicker/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -42,10 +44,12 @@ function getAcceptableFileTypesFromAList(fileTypes: Array<ValueOf<typeof CONST.A
* on a Browser we must append a hidden input to the DOM
* and listen to onChange event.
*/
function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, acceptedFileTypes}: AttachmentPickerProps): React.JSX.Element {
function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, acceptedFileTypes, fileLimit = 0, totalFilesSizeLimitInMB = 0}: AttachmentPickerProps): React.JSX.Element {
const {translate} = useLocalize();
const fileInput = useRef<HTMLInputElement>(null);
const onPicked = useRef<(file: File) => void>(() => {});
const onPicked = useRef<(files: FileObject[]) => void>(() => {});
const onCanceled = useRef<() => void>(() => {});
const totalFilesSizeLimitInBytes = totalFilesSizeLimitInMB * 1024 * 1024;

return (
<>
Expand All @@ -58,11 +62,35 @@ function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, a
return;
}

const file = e.target.files[0];
const files: FileObject[] = [...e.target.files].map((file) => ({
name: file.name,
size: file.size,
type: file.type,
uri: URL.createObjectURL(file),
}));

if (file) {
file.uri = URL.createObjectURL(file);
onPicked.current(file);
const totalSize = files.reduce((sum, file) => sum + (file.size ?? 0), 0);

if (totalSize > totalFilesSizeLimitInBytes) {
alert(translate('attachmentPicker.filesTooBigMessage'));
return;
}

if (fileLimit) {
if (files.length > 0) {
if (files.length > fileLimit) {
alert(translate('attachmentPicker.tooManyFiles', {fileLimit}));
} else {
onPicked.current(files);
}
}
} else {
const file = e.target.files[0];

if (file) {
file.uri = URL.createObjectURL(file);
onPicked.current([file]);
}
}

// Cleanup after selecting a file to start from a fresh state
Expand Down Expand Up @@ -97,6 +125,7 @@ function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, a
);
}}
accept={acceptedFileTypes ? getAcceptableFileTypesFromAList(acceptedFileTypes) : getAcceptableFileTypes(type)}
multiple={!!fileLimit}
/>
{children({
openPicker: ({onPicked: newOnPicked, onCanceled: newOnCanceled = () => {}}) => {
Expand Down
8 changes: 7 additions & 1 deletion src/components/AttachmentPicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type CONST from '@src/CONST';

type PickerOptions = {
/** A callback that will be called with the selected attachment. */
onPicked: (file: FileObject) => void;
onPicked: (files: FileObject[]) => void;
/** A callback that will be called without a selected attachment. */
onCanceled?: () => void;
};
Expand Down Expand Up @@ -49,6 +49,12 @@ type AttachmentPickerProps = {

/** Whether to validate the image and show the alert or not. */
shouldValidateImage?: boolean;

/** Whether to allow multiple files to be selected. */
fileLimit?: number;

/** The total size limit of the files that can be selected. */
totalFilesSizeLimitInMB?: number;
};

export default AttachmentPickerProps;
Loading

0 comments on commit 6854225

Please sign in to comment.