Skip to content

Commit

Permalink
Merge pull request #51104 from pasyukevich/feature/upload-picker
Browse files Browse the repository at this point in the history
feat: create upload picker component
  • Loading branch information
madmax330 authored Oct 29, 2024
2 parents 7848d60 + a32b797 commit d9fbd41
Show file tree
Hide file tree
Showing 15 changed files with 296 additions and 88 deletions.
3 changes: 3 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,9 @@ const CONST = {
},
},
NON_USD_BANK_ACCOUNT: {
ALLOWED_FILE_TYPES: ['pdf', 'jpg', 'jpeg', 'png'],
FILE_LIMIT: 10,
TOTAL_FILES_SIZE_LIMIT: 5242880,
STEP: {
COUNTRY: 'CountryStep',
BANK_INFO: 'BankInfoStep',
Expand Down
162 changes: 89 additions & 73 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,13 +111,14 @@ function AttachmentPicker({
type = CONST.ATTACHMENT_PICKER_TYPE.FILE,
children,
shouldHideCameraOption = false,
shouldHideGalleryOption = false,
shouldValidateImage = true,
shouldHideGalleryOption = false,
fileLimit = 1,
}: 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);
Expand All @@ -143,7 +144,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 +201,7 @@ function AttachmentPicker({
}
});
}),
[showGeneralAlert, type],
[fileLimit, showGeneralAlert, type],
);
/**
* Launch the DocumentPicker. Results are in the same format as ImagePicker
Expand All @@ -209,15 +210,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 +262,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 +287,7 @@ function AttachmentPicker({
}
return getDataForUpload(fileData)
.then((result) => {
completeAttachmentSelection.current(result);
completeAttachmentSelection.current([result]);
})
.catch((error: Error) => {
showGeneralAlert(error.message);
Expand All @@ -301,63 +302,78 @@ 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,
};
const filesToProcess = attachments.map((fileData) => {
if (!fileData) {
onCanceled.current();
return Promise.resolve();
}

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;
}
/* 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 {
/* 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],
[shouldValidateImage, validateAndCompleteAttachmentSelection, showGeneralAlert, showImageCorruptionAlert],
);

/**
Expand Down
8 changes: 5 additions & 3 deletions src/components/AttachmentPicker/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, {useRef} from 'react';
import type {ValueOf} from 'type-fest';
import type {FileObject} from '@components/AttachmentModal';
import * as Browser from '@libs/Browser';
import Visibility from '@libs/Visibility';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -42,9 +43,9 @@ 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, allowMultiple = false}: AttachmentPickerProps): React.JSX.Element {
const fileInput = useRef<HTMLInputElement>(null);
const onPicked = useRef<(file: File) => void>(() => {});
const onPicked = useRef<(files: FileObject[]) => void>(() => {});
const onCanceled = useRef<() => void>(() => {});

return (
Expand All @@ -62,7 +63,7 @@ function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, a

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

// Cleanup after selecting a file to start from a fresh state
Expand Down Expand Up @@ -97,6 +98,7 @@ function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, a
);
}}
accept={acceptedFileTypes ? getAcceptableFileTypesFromAList(acceptedFileTypes) : getAcceptableFileTypes(type)}
multiple={allowMultiple}
/>
{/* eslint-disable-next-line react-compiler/react-compiler */}
{children({
Expand Down
10 changes: 8 additions & 2 deletions src/components/AttachmentPicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type {FileObject} from '@components/AttachmentModal';
import type CONST from '@src/CONST';

type PickerOptions = {
/** A callback that will be called with the selected attachment. */
onPicked: (file: FileObject) => void;
/** A callback that will be called with the selected attachments. */
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;

/** Allow multiple file selection */
allowMultiple?: boolean;

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

export default AttachmentPickerProps;
8 changes: 4 additions & 4 deletions src/components/AvatarWithImagePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ type ErrorData = {
};

type OpenPickerParams = {
onPicked: (image: FileObject) => void;
onPicked: (image: FileObject[]) => void;
};
type OpenPicker = (args: OpenPickerParams) => void;

Expand Down Expand Up @@ -278,7 +278,7 @@ function AvatarWithImagePicker({
return;
}
openPicker({
onPicked: showAvatarCropModal,
onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}),
});
},
shouldCallAfterModalHide: true,
Expand Down Expand Up @@ -324,7 +324,7 @@ function AvatarWithImagePicker({
}
if (isUsingDefaultAvatar) {
openPicker({
onPicked: showAvatarCropModal,
onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}),
});
return;
}
Expand Down Expand Up @@ -426,7 +426,7 @@ function AvatarWithImagePicker({
// by the user on Safari.
if (index === 0 && Browser.isSafari()) {
openPicker({
onPicked: showAvatarCropModal,
onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}),
});
}
}}
Expand Down
Loading

0 comments on commit d9fbd41

Please sign in to comment.