Skip to content

Commit 5f4cfaf

Browse files
authored
Merge pull request Expensify#62650 from callstack-internal/feat/59442-split-screen-dropzone
[InternalQA] Split screen drop zone
2 parents 07a5a05 + 18501ad commit 5f4cfaf

18 files changed

Lines changed: 480 additions & 183 deletions

File tree

src/components/DragAndDrop/Consumer/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ function DragAndDropConsumer({children, onDrop}: DragAndDropConsumerProps) {
77
const {isDraggingOver, setOnDropHandler, dropZoneID} = useContext(DragAndDropContext);
88

99
useEffect(() => {
10+
if (!onDrop) {
11+
return;
12+
}
1013
setOnDropHandler?.(onDrop);
1114
}, [onDrop, setOnDropHandler]);
1215

src/components/DragAndDrop/Consumer/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ type DragAndDropConsumerProps = {
55
children: ReactNode;
66

77
/** Function to execute when an item is dropped in the drop zone. */
8-
onDrop: (event: DragEvent) => void;
8+
onDrop?: (event: DragEvent) => void;
99
};
1010

1111
export default DragAndDropConsumerProps;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
3+
import {View} from 'react-native';
4+
import Icon from '@components/Icon';
5+
import Text from '@components/Text';
6+
import useThemeStyles from '@hooks/useThemeStyles';
7+
import type IconAsset from '@src/types/utils/IconAsset';
8+
9+
type DropZoneUIProps = {
10+
/** Icon to display in the drop zone */
11+
icon: IconAsset;
12+
13+
/** Title to display in the drop zone */
14+
dropTitle?: string;
15+
16+
/** Custom styles for the drop zone */
17+
dropStyles?: StyleProp<ViewStyle>;
18+
19+
/** Custom styles for the drop zone text */
20+
dropTextStyles?: StyleProp<TextStyle>;
21+
22+
/** Custom styles for the inner wrapper of the drop zone */
23+
dropInnerWrapperStyles?: StyleProp<ViewStyle>;
24+
25+
/** Custom styles for the drop wrapper */
26+
dropWrapperStyles?: StyleProp<ViewStyle>;
27+
};
28+
29+
function DropZoneUI({icon, dropTitle, dropStyles, dropTextStyles, dropWrapperStyles, dropInnerWrapperStyles}: DropZoneUIProps) {
30+
const styles = useThemeStyles();
31+
32+
return (
33+
<View style={[styles.flex1, styles.dropWrapper, styles.p2, dropWrapperStyles]}>
34+
<View style={[styles.borderRadiusComponentLarge, styles.p2, styles.flex1, dropStyles]}>
35+
<View style={[styles.flex1, styles.justifyContentCenter, styles.alignItemsCenter, styles.borderRadiusComponentNormal, dropInnerWrapperStyles, styles.dropInnerWrapper]}>
36+
<View style={styles.mb3}>
37+
<Icon
38+
src={icon}
39+
width={100}
40+
height={100}
41+
/>
42+
</View>
43+
<Text style={[styles.textDropZone, dropTextStyles]}>{dropTitle}</Text>
44+
</View>
45+
</View>
46+
</View>
47+
);
48+
}
49+
50+
DropZoneUI.displayName = 'DropZoneUI';
51+
52+
export default DropZoneUI;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/* eslint-disable react-compiler/react-compiler */
2+
import React, {useRef} from 'react';
3+
import {View} from 'react-native';
4+
import useDragAndDrop from '@hooks/useDragAndDrop';
5+
import useThemeStyles from '@hooks/useThemeStyles';
6+
import htmlDivElementRef from '@src/types/utils/htmlDivElementRef';
7+
import viewRef from '@src/types/utils/viewRef';
8+
9+
type DropZoneWrapperProps = {
10+
/** Callback to execute when a file is dropped */
11+
onDrop: (event: DragEvent) => void;
12+
13+
/** Function to render the children */
14+
children: React.FC<{isDraggingOver: boolean}>;
15+
};
16+
17+
function DropZoneWrapper({onDrop, children}: DropZoneWrapperProps) {
18+
const styles = useThemeStyles();
19+
const dropZone = useRef<HTMLDivElement | View>(null);
20+
21+
const {isDraggingOver} = useDragAndDrop({
22+
shouldAcceptDrop: (event) => !!event.dataTransfer?.types.some((type) => type === 'Files'),
23+
onDrop,
24+
shouldStopPropagation: false,
25+
shouldHandleDragEvent: false,
26+
dropZone: htmlDivElementRef(dropZone),
27+
});
28+
29+
return (
30+
<View
31+
ref={viewRef(dropZone)}
32+
style={styles.flex1}
33+
>
34+
{children({isDraggingOver})}
35+
</View>
36+
);
37+
}
38+
39+
export default DropZoneWrapper;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React from 'react';
2+
import {View} from 'react-native';
3+
import DragAndDropConsumer from '@components/DragAndDrop/Consumer';
4+
import * as Expensicons from '@components/Icon/Expensicons';
5+
import useLocalize from '@hooks/useLocalize';
6+
import useResponsiveLayout from '@hooks/useResponsiveLayout';
7+
import useThemeStyles from '@hooks/useThemeStyles';
8+
import DropZoneUI from './DropZoneUI';
9+
import DropZoneWrapper from './DropZoneWrapper';
10+
11+
type DropZoneProps = {
12+
/** Whether the user is editing */
13+
isEditing: boolean;
14+
15+
/** Callback to execute when a file is dropped */
16+
onAttachmentDrop: (event: DragEvent) => void;
17+
18+
/** Callback to execute when a file is dropped */
19+
onReceiptDrop: (event: DragEvent) => void;
20+
};
21+
22+
function DualDropZone({isEditing, onAttachmentDrop, onReceiptDrop}: DropZoneProps) {
23+
const styles = useThemeStyles();
24+
const {translate} = useLocalize();
25+
const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout();
26+
27+
const shouldStackVertically = shouldUseNarrowLayout || isMediumScreenWidth;
28+
29+
return (
30+
<DragAndDropConsumer>
31+
<View style={[shouldStackVertically ? styles.flexColumn : styles.flexRow, styles.w100, styles.h100]}>
32+
<DropZoneWrapper onDrop={onAttachmentDrop}>
33+
{({isDraggingOver}) => (
34+
<DropZoneUI
35+
icon={Expensicons.MessageInABottle}
36+
dropTitle={translate('dropzone.addAttachments')}
37+
dropStyles={styles.attachmentDropOverlay(isDraggingOver)}
38+
dropTextStyles={styles.attachmentDropText}
39+
dropInnerWrapperStyles={styles.attachmentDropInnerWrapper(isDraggingOver)}
40+
dropWrapperStyles={shouldStackVertically ? styles.pb0 : styles.pr0}
41+
/>
42+
)}
43+
</DropZoneWrapper>
44+
<DropZoneWrapper onDrop={onReceiptDrop}>
45+
{({isDraggingOver}) => (
46+
<DropZoneUI
47+
icon={isEditing ? Expensicons.ReplaceReceipt : Expensicons.SmartScan}
48+
dropTitle={translate(isEditing ? 'dropzone.replaceReceipt' : 'dropzone.scanReceipts')}
49+
dropStyles={styles.receiptDropOverlay(isDraggingOver)}
50+
dropTextStyles={styles.receiptDropText}
51+
dropInnerWrapperStyles={styles.receiptDropInnerWrapper(isDraggingOver)}
52+
/>
53+
)}
54+
</DropZoneWrapper>
55+
</View>
56+
</DragAndDropConsumer>
57+
);
58+
}
59+
60+
export default DualDropZone;

src/components/DropZoneUI.tsx

Lines changed: 0 additions & 56 deletions
This file was deleted.

src/hooks/useDragAndDrop/index.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ const DROP_EVENT = 'drop';
1313
/**
1414
* @param dropZone – ref to the dropZone component
1515
*/
16-
const useDragAndDrop: UseDragAndDrop = ({dropZone, onDrop = () => {}, shouldAllowDrop = true, isDisabled = false, shouldAcceptDrop = () => true}) => {
16+
const useDragAndDrop: UseDragAndDrop = ({
17+
dropZone,
18+
onDrop = () => {},
19+
shouldAllowDrop = true,
20+
isDisabled = false,
21+
shouldAcceptDrop = () => true,
22+
shouldHandleDragEvent = true,
23+
shouldStopPropagation = true,
24+
}) => {
1725
const isFocused = useIsFocused();
1826
const [isDraggingOver, setIsDraggingOver] = useState(false);
1927
const {close: closePopover} = useContext(PopoverContext);
@@ -29,6 +37,10 @@ const useDragAndDrop: UseDragAndDrop = ({dropZone, onDrop = () => {}, shouldAllo
2937

3038
const handleDragEvent = useCallback(
3139
(event: DragEvent) => {
40+
if (!shouldHandleDragEvent) {
41+
return;
42+
}
43+
3244
const shouldAcceptThisDrop = shouldAllowDrop && shouldAcceptDrop(event);
3345

3446
if (shouldAcceptThisDrop && event.type === DRAG_ENTER_EVENT) {
@@ -44,7 +56,7 @@ const useDragAndDrop: UseDragAndDrop = ({dropZone, onDrop = () => {}, shouldAllo
4456
event.dataTransfer.effectAllowed = effect;
4557
}
4658
},
47-
[shouldAllowDrop, shouldAcceptDrop, closePopover],
59+
[shouldHandleDragEvent, shouldAllowDrop, shouldAcceptDrop, closePopover],
4860
);
4961

5062
/**
@@ -57,7 +69,9 @@ const useDragAndDrop: UseDragAndDrop = ({dropZone, onDrop = () => {}, shouldAllo
5769
}
5870

5971
event.preventDefault();
60-
event.stopPropagation();
72+
if (shouldStopPropagation) {
73+
event.stopPropagation();
74+
}
6175

6276
switch (event.type) {
6377
case DRAG_OVER_EVENT:
@@ -90,7 +104,7 @@ const useDragAndDrop: UseDragAndDrop = ({dropZone, onDrop = () => {}, shouldAllo
90104
break;
91105
}
92106
},
93-
[isFocused, isDisabled, shouldAcceptDrop, isDraggingOver, onDrop, handleDragEvent],
107+
[isFocused, isDisabled, shouldAcceptDrop, shouldStopPropagation, handleDragEvent, isDraggingOver, onDrop],
94108
);
95109

96110
useEffect(() => {

src/hooks/useDragAndDrop/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ type DragAndDropParams = {
44
shouldAllowDrop?: boolean;
55
isDisabled?: boolean;
66
shouldAcceptDrop?: (event: DragEvent) => boolean;
7+
shouldStopPropagation?: boolean;
8+
shouldHandleDragEvent?: boolean;
79
};
810

911
type DragAndDropResult = {

src/hooks/useFileValidation.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {Str} from 'expensify-common';
2+
import {useState} from 'react';
3+
import type {FileObject} from '@components/AttachmentModal';
4+
import {resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils';
5+
import CONST from '@src/CONST';
6+
import type {TranslationPaths} from '@src/languages/types';
7+
8+
function useFileValidation() {
9+
const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false);
10+
const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState<TranslationPaths>();
11+
const [attachmentInvalidReason, setAttachmentValidReason] = useState<TranslationPaths>();
12+
const [pdfFile, setPdfFile] = useState<null | FileObject>(null);
13+
const [isLoadingReceipt, setIsLoadingReceipt] = useState(false);
14+
15+
/**
16+
* Sets the upload receipt error modal content when an invalid receipt is uploaded
17+
*/
18+
const setUploadReceiptError = (isInvalid: boolean, title: TranslationPaths, reason: TranslationPaths) => {
19+
setIsAttachmentInvalid(isInvalid);
20+
setAttachmentInvalidReasonTitle(title);
21+
setAttachmentValidReason(reason);
22+
setPdfFile(null);
23+
};
24+
25+
const validateAndResizeFile = (originalFile: FileObject, setReceiptAndNavigate: (file: FileObject) => void, isPdfValidated?: boolean) => {
26+
validateReceipt(originalFile, setUploadReceiptError).then((isFileValid) => {
27+
if (!isFileValid) {
28+
return;
29+
}
30+
31+
// If we have a pdf file and if it is not validated then set the pdf file for validation and return
32+
if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) {
33+
setPdfFile(originalFile);
34+
return;
35+
}
36+
37+
// With the image size > 24MB, we use manipulateAsync to resize the image.
38+
// It takes a long time so we should display a loading indicator while the resize image progresses.
39+
if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
40+
setIsLoadingReceipt(true);
41+
}
42+
resizeImageIfNeeded(originalFile).then((resizedFile) => {
43+
setIsLoadingReceipt(false);
44+
setReceiptAndNavigate(resizedFile);
45+
});
46+
});
47+
};
48+
49+
return {
50+
validateAndResizeFile,
51+
isAttachmentInvalid,
52+
setIsAttachmentInvalid,
53+
attachmentInvalidReason,
54+
attachmentInvalidReasonTitle,
55+
setUploadReceiptError,
56+
pdfFile,
57+
setPdfFile,
58+
isLoadingReceipt,
59+
};
60+
}
61+
62+
export default useFileValidation;

src/libs/fileDownload/FileUtils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,16 @@ const validateReceipt = (file: FileObject, setUploadReceiptError: (isInvalid: bo
378378
});
379379
};
380380

381+
const getConfirmModalPrompt = (attachmentInvalidReason: TranslationPaths | undefined) => {
382+
if (!attachmentInvalidReason) {
383+
return '';
384+
}
385+
if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') {
386+
return translateLocal(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)});
387+
}
388+
return translateLocal(attachmentInvalidReason);
389+
};
390+
381391
export {
382392
showGeneralErrorAlert,
383393
showSuccessAlert,
@@ -400,4 +410,5 @@ export {
400410
resizeImageIfNeeded,
401411
createFile,
402412
validateReceipt,
413+
getConfirmModalPrompt,
403414
};

0 commit comments

Comments
 (0)