diff --git a/android/app/build.gradle b/android/app/build.gradle
index b377f6930402..1e34491b04ad 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -90,8 +90,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001037206
- versionName "1.3.72-6"
+ versionCode 1001037209
+ versionName "1.3.72-9"
}
flavorDimensions "default"
diff --git a/docs/_sass/_search-bar.scss b/docs/_sass/_search-bar.scss
index ce085878af46..a5cc8ae2ff20 100644
--- a/docs/_sass/_search-bar.scss
+++ b/docs/_sass/_search-bar.scss
@@ -23,14 +23,25 @@ $color-gray-label: $color-gray-label;
#sidebar-search {
background-color: $color-appBG;
width: 375px;
- height: 100vh;
position: fixed;
- display: block;
+ display: flex;
+ flex-direction: column;
+ bottom: 0;
top: 0;
right: 0;
z-index: 2;
}
+#sidebar-search > div:last-child {
+ flex-grow: 1;
+ overflow-y: auto;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
@media only screen and (max-width: $breakpoint-tablet) {
#sidebar-search {
width: 100%;
@@ -156,10 +167,6 @@ label.search-label {
background-color: $color-appBG;
border: $color-appBG;
font-family: "ExpensifyNeue", "Helvetica Neue", "Helvetica", Arial, sans-serif !important;
- max-height: 80vh;
- overflow-y: scroll;
- -ms-overflow-style: none;
- scrollbar-width: none;
}
/* Hide the scrollbar */
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 3a3aa7f765a8..441bd2feab92 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.72.6
+ 1.3.72.9
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 340d56aa975c..27273c7f3866 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.3.72.6
+ 1.3.72.9
diff --git a/package-lock.json b/package-lock.json
index 64abe30d6187..ff0500eb385b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.72-6",
+ "version": "1.3.72-9",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.72-6",
+ "version": "1.3.72-9",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 6f0d4d70f768..44b936c8c588 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.72-6",
+ "version": "1.3.72-9",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/patches/react-native-image-picker+5.1.0.patch b/patches/react-native-image-picker+5.1.0.patch
new file mode 100644
index 000000000000..0defc430e669
--- /dev/null
+++ b/patches/react-native-image-picker+5.1.0.patch
@@ -0,0 +1,133 @@
+diff --git a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModuleImpl.java b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModuleImpl.java
+index 89b69a8..d86ab1e 100644
+--- a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModuleImpl.java
++++ b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModuleImpl.java
+@@ -29,6 +29,120 @@ public class ImagePickerModuleImpl implements ActivityEventListener {
+ public static final int REQUEST_LAUNCH_VIDEO_CAPTURE = 13002;
+ public static final int REQUEST_LAUNCH_LIBRARY = 13003;
+
++ // Prevent svg images from being selected as they are not supported (Image component does not support them)
++ // and also because iOS does not allow them to be selected (for consistency).
++ // Since, we can't exclude a mime type, we instead allow all image mime types except 'image/svg+xml'.
++ // Image mime types are generated by merging the Android image mime type support and the IANA media-types lists.
++ // https://android.googlesource.com/platform/external/mime-support/+/main/mime.types#636
++ // https://www.iana.org/assignments/media-types/media-types.xhtml#image
++ private static final String[] ALLOWED_IMAGE_MIME_TYPES = {
++ "image/aces",
++ "image/apng",
++ "image/avci",
++ "image/avcs",
++ "image/avif",
++ "image/bmp",
++ "image/cgm",
++ "image/dicom-rle",
++ "image/dpx",
++ "image/emf",
++ "image/example",
++ "image/fits",
++ "image/g3fax",
++ "image/gif",
++ "image/heic-sequence",
++ "image/heic",
++ "image/heif-sequence",
++ "image/heif",
++ "image/hej2k",
++ "image/hsj2",
++ "image/ief",
++ "image/j2c",
++ "image/jls",
++ "image/jp2",
++ "image/jpeg",
++ "image/jph",
++ "image/jphc",
++ "image/jpm",
++ "image/jpx",
++ "image/jxr",
++ "image/jxrA",
++ "image/jxrS",
++ "image/jxs",
++ "image/jxsc",
++ "image/jxsi",
++ "image/jxss",
++ "image/ktx",
++ "image/ktx2",
++ "image/naplps",
++ "image/pcx",
++ "image/png",
++ "image/prs.btif",
++ "image/prs.pti",
++ "image/pwg-raster",
++ // "image/svg+xml",
++ "image/t38",
++ "image/tiff-fx",
++ "image/tiff",
++ "image/vnd.adobe.photoshop",
++ "image/vnd.airzip.accelerator.azv",
++ "image/vnd.cns.inf2",
++ "image/vnd.dece.graphic",
++ "image/vnd.djvu",
++ "image/vnd.dvb.subtitle",
++ "image/vnd.dwg",
++ "image/vnd.dxf",
++ "image/vnd.fastbidsheet",
++ "image/vnd.fpx",
++ "image/vnd.fst",
++ "image/vnd.fujixerox.edmics-mmr",
++ "image/vnd.fujixerox.edmics-rlc",
++ "image/vnd.globalgraphics.pgb",
++ "image/vnd.microsoft.icon",
++ "image/vnd.mix",
++ "image/vnd.mozilla.apng",
++ "image/vnd.ms-modi",
++ "image/vnd.net-fpx",
++ "image/vnd.pco.b16",
++ "image/vnd.radiance",
++ "image/vnd.sealed.png",
++ "image/vnd.sealedmedia.softseal.gif",
++ "image/vnd.sealedmedia.softseal.jpg",
++ "image/vnd.svf",
++ "image/vnd.tencent.tap",
++ "image/vnd.valve.source.texture",
++ "image/vnd.wap.wbmp",
++ "image/vnd.xiff",
++ "image/vnd.zbrush.pcx",
++ "image/webp",
++ "image/wmf",
++ "image/x-canon-cr2",
++ "image/x-canon-crw",
++ "image/x-cmu-raster",
++ "image/x-coreldraw",
++ "image/x-coreldrawpattern",
++ "image/x-coreldrawtemplate",
++ "image/x-corelphotopaint",
++ "image/x-emf",
++ "image/x-epson-erf",
++ "image/x-icon",
++ "image/x-jg",
++ "image/x-jng",
++ "image/x-ms-bmp",
++ "image/x-nikon-nef",
++ "image/x-olympus-orf",
++ "image/x-photoshop",
++ "image/x-portable-anymap",
++ "image/x-portable-bitmap",
++ "image/x-portable-graymap",
++ "image/x-portable-pixmap",
++ "image/x-rgb",
++ "image/x-wmf",
++ "image/x-xbitmap",
++ "image/x-xpixmap",
++ "image/x-xwindowdump",
++ };
++
+ private Uri fileUri;
+
+ private ReactApplicationContext reactContext;
+@@ -148,6 +262,7 @@ public class ImagePickerModuleImpl implements ActivityEventListener {
+
+ if (isPhoto) {
+ libraryIntent.setType("image/*");
++ libraryIntent.putExtra(Intent.EXTRA_MIME_TYPES, this.ALLOWED_IMAGE_MIME_TYPES);
+ } else if (isVideo) {
+ libraryIntent.setType("video/*");
+ } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
diff --git a/src/CONST.ts b/src/CONST.ts
index 5c8cd1b8f038..dcd5ac1a8db7 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1163,6 +1163,7 @@ const CONST = {
},
AVATAR_SIZE: {
+ XLARGE: 'xlarge',
LARGE: 'large',
MEDIUM: 'medium',
DEFAULT: 'default',
@@ -1311,9 +1312,9 @@ const CONST = {
},
// Auth limit is 60k for the column but we store edits and other metadata along the html so let's use a lower limit to accommodate for it.
- MAX_COMMENT_LENGTH: 15000,
+ MAX_COMMENT_LENGTH: 10000,
- // Furthermore, applying markup is very resource-consuming, so let's set a slightly lower limit for that
+ // Use the same value as MAX_COMMENT_LENGTH to ensure the entire comment is parsed. Note that applying markup is very resource-consuming.
MAX_MARKUP_LENGTH: 10000,
MAX_THREAD_REPLIES_PREVIEW: 99,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 47935e117e99..eb125a43c239 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -12,8 +12,14 @@ export default {
VALIDATE_LOGIN: 'ValidateLogin',
CONCIERGE: 'Concierge',
SETTINGS: {
+ ROOT: 'Settings_Root',
PREFERENCES: 'Settings_Preferences',
WORKSPACES: 'Settings_Workspaces',
+ SECURITY: 'Settings_Security',
+ STATUS: 'Settings_Status',
+ },
+ SAVE_THE_WORLD: {
+ ROOT: 'SaveTheWorld_Root',
},
SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop',
SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop',
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index bd6492b4237b..1b4200572664 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -34,7 +34,7 @@ const propTypes = {
onBlur: PropTypes.func,
/** Error text to display */
- errorText: PropTypes.string,
+ errorText: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]),
/** Hint text to display */
hint: PropTypes.string,
diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.js b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.js
new file mode 100644
index 000000000000..2c698d5c8a61
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {View, PixelRatio} from 'react-native';
+import useWindowDimensions from '../../../hooks/useWindowDimensions';
+import styles from '../../../styles/styles';
+
+const propTypes = {
+ /** Cell Container styles */
+ style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+};
+
+const defaultProps = {
+ style: [],
+};
+
+function AttachmentCarouselCellRenderer(props) {
+ const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
+ const modalStyles = styles.centeredModalStyles(isSmallScreenWidth, true);
+ const style = [props.style, styles.h100, {width: PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2)}];
+
+ return (
+
+ );
+}
+
+AttachmentCarouselCellRenderer.propTypes = propTypes;
+AttachmentCarouselCellRenderer.defaultProps = defaultProps;
+AttachmentCarouselCellRenderer.displayName = 'AttachmentCarouselCellRenderer';
+
+export default React.memo(AttachmentCarouselCellRenderer);
diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js
index dde75c7caad6..00b603cdd7d9 100644
--- a/src/components/Attachments/AttachmentCarousel/index.js
+++ b/src/components/Attachments/AttachmentCarousel/index.js
@@ -3,6 +3,7 @@ import {View, FlatList, PixelRatio, Keyboard} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import styles from '../../../styles/styles';
+import AttachmentCarouselCellRenderer from './AttachmentCarouselCellRenderer';
import CarouselActions from './CarouselActions';
import withWindowDimensions from '../../withWindowDimensions';
import CarouselButtons from './CarouselButtons';
@@ -12,7 +13,6 @@ import ONYXKEYS from '../../../ONYXKEYS';
import withLocalize from '../../withLocalize';
import compose from '../../../libs/compose';
import useCarouselArrows from './useCarouselArrows';
-import useWindowDimensions from '../../../hooks/useWindowDimensions';
import CarouselItem from './CarouselItem';
import Navigation from '../../../libs/Navigation/Navigation';
import BlockingView from '../../BlockingViews/BlockingView';
@@ -30,7 +30,6 @@ const viewabilityConfig = {
function AttachmentCarousel({report, reportActions, source, onNavigate, setDownloadButtonVisibility, translate}) {
const scrollRef = useRef(null);
- const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
const [containerWidth, setContainerWidth] = useState(0);
@@ -132,29 +131,6 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, setDownl
[containerWidth],
);
- /**
- * Defines how a container for a single attachment should be rendered
- * @param {Object} cellRendererProps
- * @returns {JSX.Element}
- */
- const renderCell = useCallback(
- (cellProps) => {
- // Use window width instead of layout width to address the issue in https://github.com/Expensify/App/issues/17760
- // considering horizontal margin and border width in centered modal
- const modalStyles = styles.centeredModalStyles(isSmallScreenWidth, true);
- const style = [cellProps.style, styles.h100, {width: PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2)}];
-
- return (
-
- );
- },
- [isSmallScreenWidth, windowWidth],
- );
-
/**
* Defines how a single attachment should be rendered
* @param {Object} item
@@ -226,7 +202,7 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, setDownl
windowSize={5}
maxToRenderPerBatch={3}
data={attachments}
- CellRendererComponent={renderCell}
+ CellRendererComponent={AttachmentCarouselCellRenderer}
renderItem={renderItem}
getItemLayout={getItemLayout}
keyExtractor={(item) => item.source}
diff --git a/src/components/CurrentUserPersonalDetailsSkeletonView/index.js b/src/components/CurrentUserPersonalDetailsSkeletonView/index.js
index 6e6c46e971c0..cc305a628820 100644
--- a/src/components/CurrentUserPersonalDetailsSkeletonView/index.js
+++ b/src/components/CurrentUserPersonalDetailsSkeletonView/index.js
@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
+import _ from 'underscore';
import SkeletonViewContentLoader from 'react-content-loader/native';
import {Circle, Rect} from 'react-native-svg';
import {View} from 'react-native';
@@ -12,14 +13,26 @@ import styles from '../../styles/styles';
const propTypes = {
/** Whether to animate the skeleton view */
shouldAnimate: PropTypes.bool,
+
+ /** The size of the avatar */
+ avatarSize: PropTypes.oneOf(_.values(CONST.AVATAR_SIZE)),
+
+ /** Background color of the skeleton view */
+ backgroundColor: PropTypes.string,
+
+ /** Foreground color of the skeleton view */
+ foregroundColor: PropTypes.string,
};
const defaultProps = {
shouldAnimate: true,
+ avatarSize: CONST.AVATAR_SIZE.LARGE,
+ backgroundColor: themeColors.highlightBG,
+ foregroundColor: themeColors.border,
};
function CurrentUserPersonalDetailsSkeletonView(props) {
- const avatarPlaceholderSize = StyleUtils.getAvatarSize(CONST.AVATAR_SIZE.LARGE);
+ const avatarPlaceholderSize = StyleUtils.getAvatarSize(props.avatarSize);
const avatarPlaceholderRadius = avatarPlaceholderSize / 2;
const spaceBetweenAvatarAndHeadline = styles.mb3.marginBottom + styles.mt1.marginTop + (variables.lineHeightXXLarge - variables.fontSizeXLarge) / 2;
const headlineSize = variables.fontSizeXLarge;
@@ -29,8 +42,8 @@ function CurrentUserPersonalDetailsSkeletonView(props) {
Transaction.addStop(iou.transactionID)}
+ onPress={() => {
+ const newIndex = _.size(lodashGet(transaction, 'comment.waypoints', {}));
+ Navigation.navigate(ROUTES.getMoneyRequestWaypointRoute('request', newIndex));
+ }}
text={translate('distance.addStop')}
isDisabled={numberOfWaypoints === MAX_WAYPOINTS}
innerStyles={[styles.ph10]}
@@ -294,7 +297,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken,
success
style={[styles.w100, styles.mb4, styles.ph4, styles.flexShrink0]}
onPress={navigateToNextPage}
- isDisabled={_.size(validatedWaypoints) < 2 || hasRouteError || isOffline}
+ isDisabled={_.size(validatedWaypoints) < 2 || hasRouteError}
text={translate('common.next')}
/>
diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.js
index 2aa50779e10f..7c9ec4d2db25 100644
--- a/src/components/ExceededCommentLength.js
+++ b/src/components/ExceededCommentLength.js
@@ -4,6 +4,7 @@ import {debounce} from 'lodash';
import {withOnyx} from 'react-native-onyx';
import CONST from '../CONST';
import * as ReportUtils from '../libs/ReportUtils';
+import useLocalize from '../hooks/useLocalize';
import Text from './Text';
import styles from '../styles/styles';
import ONYXKEYS from '../ONYXKEYS';
@@ -25,6 +26,7 @@ const defaultProps = {
};
function ExceededCommentLength(props) {
+ const {numberFormat, translate} = useLocalize();
const [commentLength, setCommentLength] = useState(0);
const updateCommentLength = useMemo(
() =>
@@ -44,7 +46,14 @@ function ExceededCommentLength(props) {
return null;
}
- return {`${commentLength}/${CONST.MAX_COMMENT_LENGTH}`};
+ return (
+
+ {translate('composer.commentExceededMaxLength', {formattedMaxLength: numberFormat(CONST.MAX_COMMENT_LENGTH)})}
+
+ );
}
ExceededCommentLength.propTypes = propTypes;
diff --git a/src/components/StaticHeaderPageLayout.js b/src/components/HeaderPageLayout.js
similarity index 53%
rename from src/components/StaticHeaderPageLayout.js
rename to src/components/HeaderPageLayout.js
index f97e42329942..bec1e52b1cad 100644
--- a/src/components/StaticHeaderPageLayout.js
+++ b/src/components/HeaderPageLayout.js
@@ -10,6 +10,8 @@ import themeColors from '../styles/themes/default';
import * as StyleUtils from '../styles/StyleUtils';
import useWindowDimensions from '../hooks/useWindowDimensions';
import FixedFooter from './FixedFooter';
+import useNetwork from '../hooks/useNetwork';
+import * as Browser from '../libs/Browser';
const propTypes = {
...headerWithBackButtonPropTypes,
@@ -22,16 +24,26 @@ const propTypes = {
/** A fixed footer to display at the bottom of the page. */
footer: PropTypes.node,
+
+ /** The image to display in the upper half of the screen. */
+ header: PropTypes.node,
+
+ /** Style to apply to the header image container */
+ // eslint-disable-next-line react/forbid-prop-types
+ headerContainerStyles: PropTypes.arrayOf(PropTypes.object),
};
const defaultProps = {
backgroundColor: themeColors.appBG,
+ header: null,
+ headerContainerStyles: [],
footer: null,
};
-function StaticHeaderPageLayout({backgroundColor, children, image: Image, footer, imageContainerStyle, style, ...propsToPassToHeader}) {
- const {windowHeight} = useWindowDimensions();
-
+function HeaderPageLayout({backgroundColor, children, footer, headerContainerStyles, style, headerContent, ...propsToPassToHeader}) {
+ const {windowHeight, isSmallScreenWidth} = useWindowDimensions();
+ const {isOffline} = useNetwork();
+ const appBGColor = StyleUtils.getBackgroundColorStyle(themeColors.appBG);
const {titleColor, iconFill} = useMemo(() => {
const isColorfulBackground = backgroundColor !== themeColors.appBG;
return {
@@ -45,7 +57,7 @@ function StaticHeaderPageLayout({backgroundColor, children, image: Image, footer
style={[StyleUtils.getBackgroundColorStyle(backgroundColor)]}
shouldEnablePickerAvoiding={false}
includeSafeAreaPaddingBottom={false}
- offlineIndicatorStyle={[StyleUtils.getBackgroundColorStyle(themeColors.appBG)]}
+ offlineIndicatorStyle={[appBGColor]}
>
{({safeAreaPaddingBottomStyle}) => (
<>
@@ -55,27 +67,24 @@ function StaticHeaderPageLayout({backgroundColor, children, image: Image, footer
titleColor={titleColor}
iconFill={iconFill}
/>
-
+
+ {/** Safari on ios/mac has a bug where overscrolling the page scrollview shows green background color. This is a workaround to fix that. https://github.com/Expensify/App/issues/23422 */}
+ {Browser.isSafari() && (
+
+
+
+
+ )}
-
-
-
+ {!Browser.isSafari() && }
+
+ {headerContent}
- {children}
+ {children}
{!_.isNull(footer) && {footer}}
@@ -85,8 +94,8 @@ function StaticHeaderPageLayout({backgroundColor, children, image: Image, footer
);
}
-StaticHeaderPageLayout.propTypes = propTypes;
-StaticHeaderPageLayout.defaultProps = defaultProps;
-StaticHeaderPageLayout.displayName = 'StaticHeaderPageLayout';
+HeaderPageLayout.propTypes = propTypes;
+HeaderPageLayout.defaultProps = defaultProps;
+HeaderPageLayout.displayName = 'HeaderPageLayout';
-export default StaticHeaderPageLayout;
+export default HeaderPageLayout;
diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js
index 92a9c8b8552b..ac916117094b 100644
--- a/src/components/IllustratedHeaderPageLayout.js
+++ b/src/components/IllustratedHeaderPageLayout.js
@@ -1,18 +1,10 @@
-import _ from 'underscore';
import React from 'react';
import PropTypes from 'prop-types';
-import {ScrollView, View} from 'react-native';
import Lottie from 'lottie-react-native';
import headerWithBackButtonPropTypes from './HeaderWithBackButton/headerWithBackButtonPropTypes';
-import HeaderWithBackButton from './HeaderWithBackButton';
-import ScreenWrapper from './ScreenWrapper';
import styles from '../styles/styles';
import themeColors from '../styles/themes/default';
-import * as StyleUtils from '../styles/StyleUtils';
-import useWindowDimensions from '../hooks/useWindowDimensions';
-import FixedFooter from './FixedFooter';
-import useNetwork from '../hooks/useNetwork';
-import * as Browser from '../libs/Browser';
+import HeaderPageLayout from './HeaderPageLayout';
const propTypes = {
...headerWithBackButtonPropTypes,
@@ -40,54 +32,28 @@ const defaultProps = {
};
function IllustratedHeaderPageLayout({backgroundColor, children, illustration, footer, overlayContent, ...propsToPassToHeader}) {
- const {isOffline} = useNetwork();
- const {isSmallScreenWidth, windowHeight} = useWindowDimensions();
- const appBGColor = StyleUtils.getBackgroundColorStyle(themeColors.appBG);
-
return (
-
- {({safeAreaPaddingBottomStyle}) => (
+
-
-
- {/* Safari on ios/mac has a bug where overscrolling the page scrollview shows green the background color. This is a workaround to fix that. https://github.com/Expensify/App/issues/23422 */}
- {Browser.isSafari() && (
-
-
-
-
- )}
-
- {!Browser.isSafari() && }
-
-
- {overlayContent && overlayContent()}
-
- {children}
-
- {!_.isNull(footer) && {footer}}
-
+ {overlayContent && overlayContent()}
>
- )}
-
+ }
+ headerContainerStyles={[styles.justifyContentCenter, styles.w100]}
+ footer={footer}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...propsToPassToHeader}
+ >
+ {children}
+
);
}
diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js
index 9303f078e823..e0dce180043b 100644
--- a/src/components/ImageView/index.js
+++ b/src/components/ImageView/index.js
@@ -143,11 +143,16 @@ function ImageView({isAuthTokenRequired, url, fileName}) {
*/
const onContainerPress = (e) => {
if (!isZoomed && !isDragging) {
- const {offsetX, offsetY} = e.nativeEvent;
- // Dividing clicked positions by the zoom scale to get coordinates
- // so that once we zoom we will scroll to the clicked location.
- const delta = getScrollOffset(offsetX / zoomScale, offsetY / zoomScale);
- setZoomDelta(delta);
+ if (e.nativeEvent) {
+ const {offsetX, offsetY} = e.nativeEvent;
+
+ // Dividing clicked positions by the zoom scale to get coordinates
+ // so that once we zoom we will scroll to the clicked location.
+ const delta = getScrollOffset(offsetX / zoomScale, offsetY / zoomScale);
+ setZoomDelta(delta);
+ } else {
+ setZoomDelta({offsetX: 0, offsetY: 0});
+ }
}
if (isZoomed && isDragging && isMouseDown) {
@@ -227,14 +232,14 @@ function ImageView({isAuthTokenRequired, url, fileName}) {
source={{uri: url}}
isAuthTokenRequired={isAuthTokenRequired}
// Hide image until finished loading to prevent showing preview with wrong dimensions.
- style={isLoading ? undefined : [styles.w100, styles.h100]}
+ style={isLoading || zoomScale === 0 ? undefined : [styles.w100, styles.h100]}
// When Image dimensions are lower than the container boundary(zoomscale <= 1), use `contain` to render the image with natural dimensions.
// Both `center` and `contain` keeps the image centered on both x and y axis.
resizeMode={zoomScale > 1 ? Image.resizeMode.center : Image.resizeMode.contain}
onLoadStart={imageLoadingStart}
onLoad={imageLoad}
/>
- {isLoading && }
+ {(isLoading || zoomScale === 0) && }
);
}
diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js
index 3a0741cf9bd2..2afc6240f85d 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview.js
+++ b/src/components/ReportActionItem/MoneyRequestPreview.js
@@ -170,7 +170,7 @@ function MoneyRequestPreview(props) {
!_.isEmpty(requestMerchant) && !props.isBillSplit && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT;
const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant;
- const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction.receipt.source, props.transaction.filename || props.transaction.receiptFilename || '')] : [];
+ const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction.receipt.source, props.transaction.filename || '')] : [];
const getSettledMessage = () => {
switch (lodashGet(props.action, 'originalMessage.paymentType', '')) {
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index c14e40010b54..1350c62bda88 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -120,9 +120,7 @@ function ReportPreview(props) {
const isScanning = hasReceipts && ReportUtils.areAllRequestsBeingSmartScanned(props.iouReportID, props.action);
const hasErrors = hasReceipts && ReportUtils.hasMissingSmartscanFields(props.iouReportID);
const lastThreeTransactionsWithReceipts = ReportUtils.getReportPreviewDisplayTransactions(props.action);
- const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, ({receipt, filename, receiptFilename}) =>
- ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename || receiptFilename || ''),
- );
+ const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, ({receipt, filename}) => ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename || ''));
const hasOnlyOneReceiptRequest = numberOfRequests === 1 && hasReceipts;
const previewSubtitle = hasOnlyOneReceiptRequest
diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js
index f2a1758a050b..ae77a18b980f 100644
--- a/src/components/ReportActionItem/TaskView.js
+++ b/src/components/ReportActionItem/TaskView.js
@@ -51,7 +51,8 @@ function TaskView(props) {
const isOpen = ReportUtils.isOpenTaskReport(props.report);
const isCanceled = ReportUtils.isCanceledTaskReport(props.report);
const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID);
- const disableState = !canModifyTask || !isOpen;
+ const disableState = !canModifyTask || isCanceled;
+ const isDisableInteractive = !canModifyTask || !isOpen;
return (
(
{
+ if (isDisableInteractive) {
+ return;
+ }
if (e && e.type === 'click') {
e.currentTarget.blur();
}
Navigation.navigate(ROUTES.getTaskReportTitleRoute(props.report.reportID));
})}
- style={({pressed}) => [styles.ph5, styles.pv2, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, false, disableState), true)]}
+ style={({pressed}) => [
+ styles.ph5,
+ styles.pv2,
+ StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, false, disableState, !isDisableInteractive), true),
+ isDisableInteractive && !disableState && styles.cursorDefault,
+ ]}
ref={props.forwardedRef}
disabled={disableState}
accessibilityLabel={taskTitle || props.translate('task.task')}
@@ -129,6 +138,7 @@ function TaskView(props) {
wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]}
shouldGreyOutWhenDisabled={false}
numberOfLinesTitle={0}
+ interactive={!isDisableInteractive}
/>
{props.report.managerID ? (
@@ -146,6 +156,7 @@ function TaskView(props) {
wrapperStyle={[styles.pv2]}
isSmallAvatarSubscriptMenu
shouldGreyOutWhenDisabled={false}
+ interactive={!isDisableInteractive}
/>
) : (
@@ -156,6 +167,7 @@ function TaskView(props) {
disabled={disableState}
wrapperStyle={[styles.pv2]}
shouldGreyOutWhenDisabled={false}
+ interactive={!isDisableInteractive}
/>
)}
diff --git a/src/components/withWindowDimensions/index.js b/src/components/withWindowDimensions/index.js
index a3836fa99e6b..37d5c94688a2 100644
--- a/src/components/withWindowDimensions/index.js
+++ b/src/components/withWindowDimensions/index.js
@@ -1,5 +1,6 @@
import React, {forwardRef, createContext, useState, useEffect} from 'react';
import PropTypes from 'prop-types';
+import lodashDebounce from 'lodash/debounce';
import {Dimensions} from 'react-native';
import {SafeAreaInsetsContext} from 'react-native-safe-area-context';
import getComponentDisplayName from '../../libs/getComponentDisplayName';
@@ -44,14 +45,15 @@ function WindowDimensionsProvider(props) {
useEffect(() => {
const onDimensionChange = (newDimensions) => {
const {window} = newDimensions;
-
setWindowDimension({
windowHeight: window.height,
windowWidth: window.width,
});
};
- const dimensionsEventListener = Dimensions.addEventListener('change', onDimensionChange);
+ const onDimensionChangeDebounce = lodashDebounce(onDimensionChange, 300);
+
+ const dimensionsEventListener = Dimensions.addEventListener('change', onDimensionChangeDebounce);
return () => {
if (!dimensionsEventListener) {
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 210d82b28a7d..f7c028d2a106 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -69,6 +69,7 @@ import type {
SetTheRequestParams,
UpdatedTheRequestParams,
RemovedTheRequestParams,
+ FormattedMaxLengthParams,
RequestedAmountMessageParams,
TagSelectionParams,
TranslationBase,
@@ -282,6 +283,7 @@ export default {
composer: {
noExtensionFoundForMimeType: 'No extension found for mime type',
problemGettingImageYouPasted: 'There was a problem getting the image you pasted',
+ commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum comment length is ${formattedMaxLength} characters.`,
},
baseUpdateAppModal: {
updateApp: 'Update app',
@@ -541,6 +543,7 @@ export default {
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`,
tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money`,
error: {
+ invalidAmount: 'Please enter a valid amount before continuing.',
invalidSplit: 'Split amounts do not equal total amount',
other: 'Unexpected error, please try again later',
genericCreateFailureMessage: 'Unexpected error requesting money, please try again later',
@@ -729,6 +732,7 @@ export default {
keepCodesSafe: 'Keep these recovery codes safe!',
codesLoseAccess:
'If you lose access to your authenticator app and don’t have these codes, you will lose access to your account. \n\nNote: Setting up two-factor authentication will log you out of all other active sessions.',
+ errorStepCodes: 'Please copy or download codes before continuing.',
stepVerify: 'Verify',
scanCode: 'Scan the QR code using your',
authenticatorApp: 'authenticator app',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 0048cfbb9e23..a68f33a33730 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -69,6 +69,7 @@ import type {
SetTheRequestParams,
UpdatedTheRequestParams,
RemovedTheRequestParams,
+ FormattedMaxLengthParams,
RequestedAmountMessageParams,
TagSelectionParams,
EnglishTranslation,
@@ -272,6 +273,7 @@ export default {
composer: {
noExtensionFoundForMimeType: 'No se encontró una extension para este tipo de contenido',
problemGettingImageYouPasted: 'Ha ocurrido un problema al obtener la imagen que has pegado',
+ commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `El comentario debe tener máximo ${formattedMaxLength} caracteres.`,
},
baseUpdateAppModal: {
updateApp: 'Actualizar app',
@@ -534,6 +536,7 @@ export default {
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`,
tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero`,
error: {
+ invalidAmount: 'Por favor ingresa un monto válido antes de continuar.',
invalidSplit: 'La suma de las partes no equivale al monto total',
other: 'Error inesperado, por favor inténtalo más tarde',
genericCreateFailureMessage: 'Error inesperado solicitando dinero, Por favor, inténtalo más tarde',
@@ -724,6 +727,7 @@ export default {
keepCodesSafe: '¡Guarda los códigos de recuperación en un lugar seguro!',
codesLoseAccess:
'Si pierdes el acceso a tu aplicación de autenticación y no tienes estos códigos, perderás el acceso a tu cuenta. \n\nNota: Configurar la autenticación de dos factores cerrará la sesión de todas las demás sesiones activas.',
+ errorStepCodes: 'Copia o descarga los códigos antes de continuar.',
stepVerify: 'Verificar',
scanCode: 'Escanea el código QR usando tu',
authenticatorApp: 'aplicación de autenticación',
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 9af00ceef8de..70bf2e4cae3d 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -190,6 +190,8 @@ type RemovedTheRequestParams = {valueName: string; oldValueToDisplay: string};
type UpdatedTheRequestParams = {valueName: string; newValueToDisplay: string; oldValueToDisplay: string};
+type FormattedMaxLengthParams = {formattedMaxLength: string};
+
type TagSelectionParams = {tagName: string};
/* Translation Object types */
@@ -303,5 +305,6 @@ export type {
SetTheRequestParams,
UpdatedTheRequestParams,
RemovedTheRequestParams,
+ FormattedMaxLengthParams,
TagSelectionParams,
};
diff --git a/src/libs/CurrencyUtils.js b/src/libs/CurrencyUtils.js
index 6cbb0db7661b..5cf0b22ef337 100644
--- a/src/libs/CurrencyUtils.js
+++ b/src/libs/CurrencyUtils.js
@@ -128,4 +128,25 @@ function convertToDisplayString(amountInCents, currency = CONST.CURRENCY.USD) {
});
}
-export {getCurrencyDecimals, getCurrencyUnit, getLocalizedCurrencySymbol, getCurrencySymbol, isCurrencySymbolLTR, convertToBackendAmount, convertToFrontendAmount, convertToDisplayString};
+/**
+ * Checks if passed currency code is a valid currency based on currency list
+ *
+ * @param {String} currencyCode
+ * @returns {Boolean}
+ */
+function isValidCurrencyCode(currencyCode) {
+ const currency = lodashGet(currencyList, currencyCode);
+ return Boolean(currency);
+}
+
+export {
+ getCurrencyDecimals,
+ getCurrencyUnit,
+ getLocalizedCurrencySymbol,
+ getCurrencySymbol,
+ isCurrencySymbolLTR,
+ convertToBackendAmount,
+ convertToFrontendAmount,
+ convertToDisplayString,
+ isValidCurrencyCode,
+};
diff --git a/src/libs/Log.js b/src/libs/Log.ts
similarity index 70%
rename from src/libs/Log.js
rename to src/libs/Log.ts
index e51fb74aedd5..cf139eec2682 100644
--- a/src/libs/Log.js
+++ b/src/libs/Log.ts
@@ -1,45 +1,43 @@
// Making an exception to this rule here since we don't need an "action" for Log and Log should just be used directly. Creating a Log
// action would likely cause confusion about which one to use. But most other API methods should happen inside an action file.
/* eslint-disable rulesdir/no-api-in-views */
+import {Merge} from 'type-fest';
import Logger from 'expensify-common/lib/Logger';
import getPlatform from './getPlatform';
import pkg from '../../package.json';
import requireParameters from './requireParameters';
import * as Network from './Network';
-let timeout = null;
+let timeout: NodeJS.Timeout;
-/**
- * @param {Object} parameters
- * @param {String} parameters.expensifyCashAppVersion
- * @param {Object[]} parameters.logPacket
- * @returns {Promise}
- */
-function LogCommand(parameters) {
+type LogCommandParameters = {
+ expensifyCashAppVersion: string;
+ logPacket: string;
+};
+
+function LogCommand(parameters: LogCommandParameters): Promise<{requestID: string}> {
const commandName = 'Log';
requireParameters(['logPacket', 'expensifyCashAppVersion'], parameters, commandName);
// Note: We are forcing Log to run since it requires no authToken and should only be queued when we are offline.
// Non-cancellable request: during logout, when requests are cancelled, we don't want to cancel any remaining logs
- return Network.post(commandName, {...parameters, forceNetworkRequest: true, canCancel: false});
+ return Network.post(commandName, {...parameters, forceNetworkRequest: true, canCancel: false}) as Promise<{requestID: string}>;
}
+// eslint-disable-next-line
+type ServerLoggingCallbackOptions = {api_setCookie: boolean; logPacket: string};
+type RequestParams = Merge;
+
/**
* Network interface for logger.
- *
- * @param {Logger} logger
- * @param {Object} params
- * @param {Object} params.parameters
- * @param {String} params.message
- * @return {Promise}
*/
-function serverLoggingCallback(logger, params) {
- const requestParams = params;
+function serverLoggingCallback(logger: Logger, params: ServerLoggingCallbackOptions): Promise<{requestID: string}> {
+ const requestParams = params as RequestParams;
requestParams.shouldProcessImmediately = false;
requestParams.shouldRetry = false;
requestParams.expensifyCashAppVersion = `expensifyCash[${getPlatform()}]${pkg.version}`;
if (requestParams.parameters) {
- requestParams.parameters = JSON.stringify(params.parameters);
+ requestParams.parameters = JSON.stringify(requestParams.parameters);
}
clearTimeout(timeout);
timeout = setTimeout(() => logger.info('Flushing logs older than 10 minutes', true, {}, true), 10 * 60 * 1000);
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index 392781a777db..5c110264e034 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -334,7 +334,7 @@ const NewTeachersUniteNavigator = createModalStackNavigator([
const SaveTheWorldPage = require('../../../pages/TeachersUnite/SaveTheWorldPage').default;
return SaveTheWorldPage;
},
- name: 'SaveTheWorld_Root',
+ name: SCREENS.SAVE_THE_WORLD.ROOT,
},
{
getComponent: () => {
@@ -365,7 +365,7 @@ const SettingsModalStackNavigator = createModalStackNavigator([
const SettingsInitialPage = require('../../../pages/settings/InitialSettingsPage').default;
return SettingsInitialPage;
},
- name: 'Settings_Root',
+ name: SCREENS.SETTINGS.ROOT,
},
{
getComponent: () => {
@@ -506,7 +506,7 @@ const SettingsModalStackNavigator = createModalStackNavigator([
const SettingsSecurityPage = require('../../../pages/settings/Security/SecuritySettingsPage').default;
return SettingsSecurityPage;
},
- name: 'Settings_Security',
+ name: SCREENS.SETTINGS.SECURITY,
},
{
getComponent: () => {
@@ -576,7 +576,7 @@ const SettingsModalStackNavigator = createModalStackNavigator([
const SettingsStatus = require('../../../pages/settings/Profile/CustomStatus/StatusPage').default;
return SettingsStatus;
},
- name: 'Settings_Status',
+ name: SCREENS.SETTINGS.STATUS,
},
{
getComponent: () => {
diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js
index d8cb96e2c6b3..4d50a1cd6a68 100644
--- a/src/libs/Navigation/NavigationRoot.js
+++ b/src/libs/Navigation/NavigationRoot.js
@@ -103,7 +103,8 @@ function NavigationRoot(props) {
prevStatusBarBackgroundColor.current = statusBarBackgroundColor.current;
statusBarBackgroundColor.current = currentScreenBackgroundColor;
- if (prevStatusBarBackgroundColor.current === statusBarBackgroundColor.current) {
+
+ if (currentScreenBackgroundColor === themeColors.appBG && prevStatusBarBackgroundColor.current === themeColors.appBG) {
return;
}
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index 11d21d6d005c..f4420330fbd9 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -38,7 +38,7 @@ export default {
screens: {
Settings: {
screens: {
- Settings_Root: {
+ [SCREENS.SETTINGS.ROOT]: {
path: ROUTES.SETTINGS,
},
[SCREENS.SETTINGS.WORKSPACES]: {
@@ -65,7 +65,7 @@ export default {
path: ROUTES.SETTINGS_CLOSE,
exact: true,
},
- Settings_Security: {
+ [SCREENS.SETTINGS.SECURITY]: {
path: ROUTES.SETTINGS_SECURITY,
exact: true,
},
@@ -159,7 +159,7 @@ export default {
path: ROUTES.SETTINGS_SHARE_CODE,
exact: true,
},
- Settings_Status: {
+ [SCREENS.SETTINGS.STATUS]: {
path: ROUTES.SETTINGS_STATUS,
exact: true,
},
@@ -273,7 +273,7 @@ export default {
},
TeachersUnite: {
screens: {
- SaveTheWorld_Root: ROUTES.TEACHERS_UNITE,
+ [SCREENS.SAVE_THE_WORLD.ROOT]: ROUTES.TEACHERS_UNITE,
I_Know_A_Teacher: ROUTES.I_KNOW_A_TEACHER,
Intro_School_Principal: ROUTES.INTRO_SCHOOL_PRINCIPAL,
I_Am_A_Teacher: ROUTES.I_AM_A_TEACHER,
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 45bc6dffd67a..3c6e879bd423 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -114,6 +114,7 @@ function getPolicyExpenseReportOption(report) {
],
selected: report.selected,
isPolicyExpenseChat: true,
+ searchText: report.searchText,
};
}
@@ -226,6 +227,7 @@ function getParticipantsOption(participant, personalDetails) {
],
phoneNumber: lodashGet(detail, 'phoneNumber', ''),
selected: participant.selected,
+ searchText: participant.searchText,
};
}
diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js
deleted file mode 100644
index 0294236b1cd7..000000000000
--- a/src/libs/Permissions.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import _ from 'underscore';
-import CONST from '../CONST';
-
-/**
- * @private
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseAllBetas(betas) {
- return _.contains(betas, CONST.BETAS.ALL);
-}
-
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseChronos(betas) {
- return _.contains(betas, CONST.BETAS.CHRONOS_IN_CASH) || canUseAllBetas(betas);
-}
-
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUsePayWithExpensify(betas) {
- return _.contains(betas, CONST.BETAS.PAY_WITH_EXPENSIFY) || canUseAllBetas(betas);
-}
-
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseDefaultRooms(betas) {
- return _.contains(betas, CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas);
-}
-
-/**
- * IOU Send feature is temporarily disabled.
- *
- * @returns {Boolean}
- */
-function canUseIOUSend() {
- return false;
-}
-
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseWallet(betas) {
- return _.contains(betas, CONST.BETAS.BETA_EXPENSIFY_WALLET) || canUseAllBetas(betas);
-}
-
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseCommentLinking(betas) {
- return _.contains(betas, CONST.BETAS.BETA_COMMENT_LINKING) || canUseAllBetas(betas);
-}
-
-/**
- * We're requiring you to be added to the policy rooms beta on dev,
- * since contributors have been reporting a number of false issues related to the feature being under development.
- * See https://expensify.slack.com/archives/C01GTK53T8Q/p1641921996319400?thread_ts=1641598356.166900&cid=C01GTK53T8Q
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUsePolicyRooms(betas) {
- return _.contains(betas, CONST.BETAS.POLICY_ROOMS) || canUseAllBetas(betas);
-}
-
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseTasks(betas) {
- return _.contains(betas, CONST.BETAS.TASKS) || canUseAllBetas(betas);
-}
-
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseCustomStatus(betas) {
- return _.contains(betas, CONST.BETAS.CUSTOM_STATUS) || canUseAllBetas(betas);
-}
-
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseCategories(betas) {
- return _.contains(betas, CONST.BETAS.NEW_DOT_CATEGORIES) || canUseAllBetas(betas);
-}
-
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUseTags(betas) {
- return _.contains(betas, CONST.BETAS.NEW_DOT_TAGS) || canUseAllBetas(betas);
-}
-
-/**
- * Link previews are temporarily disabled.
- * @returns {Boolean}
- */
-function canUseLinkPreviews() {
- return false;
-}
-
-export default {
- canUseChronos,
- canUsePayWithExpensify,
- canUseDefaultRooms,
- canUseIOUSend,
- canUseWallet,
- canUseCommentLinking,
- canUsePolicyRooms,
- canUseTasks,
- canUseCustomStatus,
- canUseCategories,
- canUseTags,
- canUseLinkPreviews,
-};
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
new file mode 100644
index 000000000000..05322472a407
--- /dev/null
+++ b/src/libs/Permissions.ts
@@ -0,0 +1,80 @@
+import CONST from '../CONST';
+import Beta from '../types/onyx/Beta';
+
+function canUseAllBetas(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.ALL);
+}
+
+function canUseChronos(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.CHRONOS_IN_CASH) || canUseAllBetas(betas);
+}
+
+function canUsePayWithExpensify(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.PAY_WITH_EXPENSIFY) || canUseAllBetas(betas);
+}
+
+function canUseDefaultRooms(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas);
+}
+
+/**
+ * IOU Send feature is temporarily disabled.
+ */
+function canUseIOUSend(): boolean {
+ return false;
+}
+
+function canUseWallet(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.BETA_EXPENSIFY_WALLET) || canUseAllBetas(betas);
+}
+
+function canUseCommentLinking(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.BETA_COMMENT_LINKING) || canUseAllBetas(betas);
+}
+
+/**
+ * We're requiring you to be added to the policy rooms beta on dev,
+ * since contributors have been reporting a number of false issues related to the feature being under development.
+ * See https://expensify.slack.com/archives/C01GTK53T8Q/p1641921996319400?thread_ts=1641598356.166900&cid=C01GTK53T8Q
+ */
+function canUsePolicyRooms(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.POLICY_ROOMS) || canUseAllBetas(betas);
+}
+
+function canUseTasks(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.TASKS) || canUseAllBetas(betas);
+}
+
+function canUseCustomStatus(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.CUSTOM_STATUS) || canUseAllBetas(betas);
+}
+
+function canUseCategories(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.NEW_DOT_CATEGORIES) || canUseAllBetas(betas);
+}
+
+function canUseTags(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.NEW_DOT_TAGS) || canUseAllBetas(betas);
+}
+
+/**
+ * Link previews are temporarily disabled.
+ */
+function canUseLinkPreviews(): boolean {
+ return false;
+}
+
+export default {
+ canUseChronos,
+ canUsePayWithExpensify,
+ canUseDefaultRooms,
+ canUseIOUSend,
+ canUseWallet,
+ canUseCommentLinking,
+ canUsePolicyRooms,
+ canUseTasks,
+ canUseCustomStatus,
+ canUseCategories,
+ canUseTags,
+ canUseLinkPreviews,
+};
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index 5e328a156a23..edf646d0266b 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -1817,7 +1817,7 @@ function hasReportNameError(report) {
}
/**
- * For comments shorter than 10k chars, convert the comment from MD into HTML because that's how it is stored in the database
+ * For comments shorter than or equal to 10k chars, convert the comment from MD into HTML because that's how it is stored in the database
* For longer comments, skip parsing, but still escape the text, and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!!
*
* @param {String} text
@@ -1825,7 +1825,7 @@ function hasReportNameError(report) {
*/
function getParsedComment(text) {
const parser = new ExpensiMark();
- return text.length < CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : _.escape(text);
+ return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : _.escape(text);
}
/**
@@ -2646,11 +2646,12 @@ function buildOptimisticWorkspaceChats(policyID, policyName) {
* @param {String} parentReportID - Report ID of the chat where the Task is.
* @param {String} title - Task title.
* @param {String} description - Task description.
+ * @param {String | undefined} policyID - PolicyID of the parent report
*
* @returns {Object}
*/
-function buildOptimisticTaskReport(ownerAccountID, assigneeAccountID = 0, parentReportID, title, description) {
+function buildOptimisticTaskReport(ownerAccountID, assigneeAccountID = 0, parentReportID, title, description, policyID = undefined) {
return {
reportID: generateReportID(),
reportName: title,
@@ -2662,6 +2663,7 @@ function buildOptimisticTaskReport(ownerAccountID, assigneeAccountID = 0, parent
parentReportID,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS.OPEN,
+ ...(_.isUndefined(policyID) ? {} : {policyID}),
};
}
diff --git a/src/libs/StatusBar/index.android.js b/src/libs/StatusBar/index.android.ts
similarity index 74%
rename from src/libs/StatusBar/index.android.js
rename to src/libs/StatusBar/index.android.ts
index 5033000d4de5..c928f0949665 100644
--- a/src/libs/StatusBar/index.android.js
+++ b/src/libs/StatusBar/index.android.ts
@@ -1,5 +1,4 @@
-// eslint-disable-next-line no-restricted-imports
-import {StatusBar} from 'react-native';
+import StatusBar from './types';
// Only has custom web implementation
StatusBar.getBackgroundColor = () => null;
@@ -8,5 +7,4 @@ StatusBar.getBackgroundColor = () => null;
// Also because Reanimated's interpolateColor gives Android native colors instead of hex strings, causing this to display a warning.
StatusBar.setBackgroundColor = () => null;
-// Just export StatusBar – no changes.
export default StatusBar;
diff --git a/src/libs/StatusBar/index.js b/src/libs/StatusBar/index.ts
similarity index 74%
rename from src/libs/StatusBar/index.js
rename to src/libs/StatusBar/index.ts
index ef15d597f93e..c1290bccaa77 100644
--- a/src/libs/StatusBar/index.js
+++ b/src/libs/StatusBar/index.ts
@@ -1,5 +1,4 @@
-// eslint-disable-next-line no-restricted-imports
-import {StatusBar} from 'react-native';
+import StatusBar from './types';
// Only has custom web implementation
StatusBar.getBackgroundColor = () => null;
diff --git a/src/libs/StatusBar/index.web.js b/src/libs/StatusBar/index.web.js
deleted file mode 100644
index dfa1226c33a8..000000000000
--- a/src/libs/StatusBar/index.web.js
+++ /dev/null
@@ -1,20 +0,0 @@
-// eslint-disable-next-line no-restricted-imports
-import {StatusBar} from 'react-native';
-
-StatusBar.getBackgroundColor = () => {
- const element = document.querySelector('meta[name=theme-color]');
- if (!element || !element.content) {
- return null;
- }
- return element.content;
-};
-
-StatusBar.setBackgroundColor = (backgroundColor) => {
- const element = document.querySelector('meta[name=theme-color]');
- if (!element) {
- return;
- }
- element.content = backgroundColor;
-};
-
-export default StatusBar;
diff --git a/src/libs/StatusBar/index.web.ts b/src/libs/StatusBar/index.web.ts
new file mode 100644
index 000000000000..1d46397eb4b7
--- /dev/null
+++ b/src/libs/StatusBar/index.web.ts
@@ -0,0 +1,23 @@
+import StatusBar from './types';
+
+StatusBar.getBackgroundColor = () => {
+ const element = document.querySelector('meta[name=theme-color]');
+
+ if (!element?.content) {
+ return null;
+ }
+
+ return element.content;
+};
+
+StatusBar.setBackgroundColor = (backgroundColor) => {
+ const element = document.querySelector('meta[name=theme-color]');
+
+ if (!element) {
+ return;
+ }
+
+ element.content = backgroundColor as string;
+};
+
+export default StatusBar;
diff --git a/src/libs/StatusBar/types.ts b/src/libs/StatusBar/types.ts
new file mode 100644
index 000000000000..9098eed977ab
--- /dev/null
+++ b/src/libs/StatusBar/types.ts
@@ -0,0 +1,10 @@
+// eslint-disable-next-line no-restricted-imports
+import {StatusBar as StatusBarRN} from 'react-native';
+
+type StatusBarExtended = typeof StatusBarRN & {
+ getBackgroundColor(): string | null;
+};
+
+const StatusBar = StatusBarRN as StatusBarExtended;
+
+export default StatusBar;
diff --git a/src/libs/__mocks__/Log.js b/src/libs/__mocks__/Log.js
deleted file mode 100644
index 179a665d2bd9..000000000000
--- a/src/libs/__mocks__/Log.js
+++ /dev/null
@@ -1,9 +0,0 @@
-// Set up manual mocks for any Logging methods that are supposed hit the 'server',
-// this is needed because before, the Logging queue would get flushed while tests were running,
-// causing unexpected calls to HttpUtils.xhr() which would cause mock mismatches and flaky tests.
-export default {
- info: (message) => console.debug(`[info] ${message} (mocked)`),
- alert: (message) => console.debug(`[alert] ${message} (mocked)`),
- warn: (message) => console.debug(`[warn] ${message} (mocked)`),
- hmmm: (message) => console.debug(`[hmmm] ${message} (mocked)`),
-};
diff --git a/src/libs/__mocks__/Log.ts b/src/libs/__mocks__/Log.ts
new file mode 100644
index 000000000000..39336db1fa51
--- /dev/null
+++ b/src/libs/__mocks__/Log.ts
@@ -0,0 +1,9 @@
+// Set up manual mocks for any Logging methods that are supposed hit the 'server',
+// this is needed because before, the Logging queue would get flushed while tests were running,
+// causing unexpected calls to HttpUtils.xhr() which would cause mock mismatches and flaky tests.
+export default {
+ info: (message: string) => console.debug(`[info] ${message} (mocked)`),
+ alert: (message: string) => console.debug(`[alert] ${message} (mocked)`),
+ warn: (message: string) => console.debug(`[warn] ${message} (mocked)`),
+ hmmm: (message: string) => console.debug(`[hmmm] ${message} (mocked)`),
+};
diff --git a/src/libs/__mocks__/Permissions.js b/src/libs/__mocks__/Permissions.ts
similarity index 54%
rename from src/libs/__mocks__/Permissions.js
rename to src/libs/__mocks__/Permissions.ts
index fffaea5793d4..2c062590573e 100644
--- a/src/libs/__mocks__/Permissions.js
+++ b/src/libs/__mocks__/Permissions.ts
@@ -1,5 +1,5 @@
-import _ from 'underscore';
import CONST from '../../CONST';
+import Beta from '../../types/onyx/Beta';
/**
* This module is mocked in tests because all the permission methods call canUseAllBetas() and that will
@@ -10,8 +10,8 @@ import CONST from '../../CONST';
export default {
...jest.requireActual('../Permissions'),
- canUseDefaultRooms: (betas) => _.contains(betas, CONST.BETAS.DEFAULT_ROOMS),
- canUsePolicyRooms: (betas) => _.contains(betas, CONST.BETAS.POLICY_ROOMS),
- canUseIOUSend: (betas) => _.contains(betas, CONST.BETAS.IOU_SEND),
- canUseCustomStatus: (betas) => _.contains(betas, CONST.BETAS.CUSTOM_STATUS),
+ canUseDefaultRooms: (betas: Beta[]) => betas.includes(CONST.BETAS.DEFAULT_ROOMS),
+ canUsePolicyRooms: (betas: Beta[]) => betas.includes(CONST.BETAS.POLICY_ROOMS),
+ canUseIOUSend: (betas: Beta[]) => betas.includes(CONST.BETAS.IOU_SEND),
+ canUseCustomStatus: (betas: Beta[]) => betas.includes(CONST.BETAS.CUSTOM_STATUS),
};
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index 55b03110e925..aa0d4b432da4 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -1066,7 +1066,7 @@ const removeLinksFromHtml = (html, links) => {
*/
const handleUserDeletedLinksInHtml = (newCommentText, originalHtml) => {
const parser = new ExpensiMark();
- if (newCommentText.length >= CONST.MAX_MARKUP_LENGTH) {
+ if (newCommentText.length > CONST.MAX_MARKUP_LENGTH) {
return newCommentText;
}
const markdownOriginalComment = parser.htmlToMarkdown(originalHtml).trim();
@@ -1093,10 +1093,10 @@ function editReportComment(reportID, originalReportAction, textForNewComment) {
const htmlForNewComment = handleUserDeletedLinksInHtml(textForNewComment, originalCommentHTML);
const reportComment = parser.htmlToText(htmlForNewComment);
- // For comments shorter than 10k chars, convert the comment from MD into HTML because that's how it is stored in the database
+ // For comments shorter than or equal to 10k chars, convert the comment from MD into HTML because that's how it is stored in the database
// For longer comments, skip parsing and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!!
let parsedOriginalCommentHTML = originalCommentHTML;
- if (textForNewComment.length < CONST.MAX_MARKUP_LENGTH) {
+ if (textForNewComment.length <= CONST.MAX_MARKUP_LENGTH) {
const autolinkFilter = {filterRules: _.filter(_.pluck(parser.rules, 'name'), (name) => name !== 'autolink')};
parsedOriginalCommentHTML = parser.replace(parser.htmlToMarkdown(originalCommentHTML).trim(), autolinkFilter);
}
diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js
index f1fb4d96f523..4e71a8793f1e 100644
--- a/src/libs/actions/Task.js
+++ b/src/libs/actions/Task.js
@@ -59,9 +59,10 @@ function clearOutTaskInfo() {
* @param {String} assigneeEmail
* @param {Number} assigneeAccountID
* @param {Object} assigneeChatReport - The chat report between you and the assignee
+ * @param {String | undefined} policyID - the policyID of the parent report
*/
-function createTaskAndNavigate(parentReportID, title, description, assigneeEmail, assigneeAccountID = 0, assigneeChatReport = null) {
- const optimisticTaskReport = ReportUtils.buildOptimisticTaskReport(currentUserAccountID, assigneeAccountID, parentReportID, title, description);
+function createTaskAndNavigate(parentReportID, title, description, assigneeEmail, assigneeAccountID = 0, assigneeChatReport = null, policyID = undefined) {
+ const optimisticTaskReport = ReportUtils.buildOptimisticTaskReport(currentUserAccountID, assigneeAccountID, parentReportID, title, description, policyID);
const assigneeChatReportID = assigneeChatReport ? assigneeChatReport.reportID : 0;
const taskReportID = optimisticTaskReport.reportID;
diff --git a/src/libs/actions/Transaction.js b/src/libs/actions/Transaction.js
index 663dfd1c8564..81764a9c62be 100644
--- a/src/libs/actions/Transaction.js
+++ b/src/libs/actions/Transaction.js
@@ -138,6 +138,10 @@ function removeWaypoint(transactionID, currentIndex) {
if (!isRemovedWaypointEmpty) {
newTransaction = {
...newTransaction,
+ // Clear any errors that may be present, which apply to the old route
+ errorFields: {
+ route: null,
+ },
// Clear the existing route so that we don't show an old route
routes: {
route0: {
diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js
index e691ea22ba79..e401cbd9db69 100644
--- a/src/libs/migrateOnyx.js
+++ b/src/libs/migrateOnyx.js
@@ -1,12 +1,12 @@
import _ from 'underscore';
import Log from './Log';
import AddEncryptedAuthToken from './migrations/AddEncryptedAuthToken';
-import RenameActiveClientsKey from './migrations/RenameActiveClientsKey';
import RenamePriorityModeKey from './migrations/RenamePriorityModeKey';
import MoveToIndexedDB from './migrations/MoveToIndexedDB';
import RenameExpensifyNewsStatus from './migrations/RenameExpensifyNewsStatus';
import AddLastVisibleActionCreated from './migrations/AddLastVisibleActionCreated';
import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID';
+import RenameReceiptFilename from './migrations/RenameReceiptFilename';
export default function () {
const startTime = Date.now();
@@ -16,12 +16,12 @@ export default function () {
// Add all migrations to an array so they are executed in order
const migrationPromises = [
MoveToIndexedDB,
- RenameActiveClientsKey,
RenamePriorityModeKey,
AddEncryptedAuthToken,
RenameExpensifyNewsStatus,
AddLastVisibleActionCreated,
PersonalDetailsByAccountID,
+ RenameReceiptFilename,
];
// Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the
diff --git a/src/libs/migrations/RenameActiveClientsKey.js b/src/libs/migrations/RenameActiveClientsKey.js
deleted file mode 100644
index 54b36e13cff5..000000000000
--- a/src/libs/migrations/RenameActiveClientsKey.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import Log from '../Log';
-import ONYXKEYS from '../../ONYXKEYS';
-
-// This migration changes the name of the Onyx key ACTIVE_CLIENTS from activeClients2 to activeClients
-export default function () {
- return new Promise((resolve) => {
- // Connect to the old key in Onyx to get the old value of activeClients2
- // then set the new key activeClients to hold the old data
- // finally remove the old key by setting the value to null
- const connectionID = Onyx.connect({
- key: 'activeClients2',
- callback: (oldActiveClients) => {
- Onyx.disconnect(connectionID);
-
- // Fail early here because there is nothing to migrate
- if (_.isEmpty(oldActiveClients)) {
- Log.info('[Migrate Onyx] Skipped migration RenameActiveClientsKey');
- return resolve();
- }
-
- Onyx.multiSet({
- activeClients2: null,
- [ONYXKEYS.ACTIVE_CLIENTS]: oldActiveClients,
- }).then(() => {
- Log.info('[Migrate Onyx] Ran migration RenameActiveClientsKey');
- resolve();
- });
- },
- });
- });
-}
diff --git a/src/libs/migrations/RenameReceiptFilename.js b/src/libs/migrations/RenameReceiptFilename.js
new file mode 100644
index 000000000000..b8df705fd7d1
--- /dev/null
+++ b/src/libs/migrations/RenameReceiptFilename.js
@@ -0,0 +1,57 @@
+import Onyx from 'react-native-onyx';
+import _ from 'underscore';
+import lodashHas from 'lodash/has';
+import ONYXKEYS from '../../ONYXKEYS';
+import Log from '../Log';
+
+// This migration changes the property name on a transaction from receiptFilename to filename so that it matches what is stored in the database
+export default function () {
+ return new Promise((resolve) => {
+ // Connect to the TRANSACTION collection key in Onyx to get all of the stored transactions.
+ // Go through each transaction and change the property name
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ waitForCollectionCallback: true,
+ callback: (transactions) => {
+ Onyx.disconnect(connectionID);
+
+ if (!transactions || transactions.length === 0) {
+ Log.info('[Migrate Onyx] Skipped migration RenameReceiptFilename because there are no transactions');
+ return resolve();
+ }
+
+ if (!_.compact(_.pluck(transactions, 'receiptFilename')).length) {
+ Log.info('[Migrate Onyx] Skipped migration RenameReceiptFilename because there were no transactions with the receiptFilename property');
+ return resolve();
+ }
+
+ Log.info('[Migrate Onyx] Running RenameReceiptFilename migration');
+
+ const dataToSave = _.reduce(
+ transactions,
+ (result, transaction) => {
+ // Do nothing if there is no receiptFilename property
+ if (!lodashHas(transaction, 'receiptFilename')) {
+ return result;
+ }
+ Log.info(`[Migrate Onyx] Renaming receiptFilename ${transaction.receiptFilename} to filename`);
+ return {
+ ...result,
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: {
+ filename: transaction.receiptFilename,
+ receiptFilename: null,
+ },
+ };
+ },
+ {},
+ );
+
+ // eslint-disable-next-line rulesdir/prefer-actions-set-data
+ Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION, dataToSave).then(() => {
+ Log.info(`[Migrate Onyx] Ran migration RenameReceiptFilename and renamed ${_.size(dataToSave)} properties`);
+ resolve();
+ });
+ },
+ });
+ });
+}
diff --git a/src/pages/FlagCommentPage.js b/src/pages/FlagCommentPage.js
index 0b850e6c3849..1a9a2d8c8767 100644
--- a/src/pages/FlagCommentPage.js
+++ b/src/pages/FlagCommentPage.js
@@ -2,6 +2,7 @@ import React, {useCallback} from 'react';
import _ from 'underscore';
import {View, ScrollView} from 'react-native';
import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
import reportPropTypes from './reportPropTypes';
import reportActionPropTypes from './home/report/reportActionPropTypes';
import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
@@ -20,6 +21,7 @@ import * as ReportActionsUtils from '../libs/ReportActionsUtils';
import * as Session from '../libs/actions/Session';
import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView';
import withReportAndReportActionOrNotFound from './home/report/withReportAndReportActionOrNotFound';
+import ONYXKEYS from '../ONYXKEYS';
const propTypes = {
/** Array of report actions for this report */
@@ -178,4 +180,13 @@ FlagCommentPage.propTypes = propTypes;
FlagCommentPage.defaultProps = defaultProps;
FlagCommentPage.displayName = 'FlagCommentPage';
-export default compose(withLocalize, withReportAndReportActionOrNotFound)(FlagCommentPage);
+export default compose(
+ withLocalize,
+ withReportAndReportActionOrNotFound,
+ withOnyx({
+ parentReportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID || report.reportID}`,
+ canEvict: false,
+ },
+ }),
+)(FlagCommentPage);
diff --git a/src/pages/NewChatSelectorPage.js b/src/pages/NewChatSelectorPage.js
index 89a3fd1adc72..ce0bbda0d239 100755
--- a/src/pages/NewChatSelectorPage.js
+++ b/src/pages/NewChatSelectorPage.js
@@ -1,4 +1,5 @@
import React from 'react';
+import {withOnyx} from 'react-native-onyx';
import OnyxTabNavigator, {TopTab} from '../libs/Navigation/OnyxTabNavigator';
import TabSelector from '../components/TabSelector/TabSelector';
import Navigation from '../libs/Navigation/Navigation';
@@ -6,6 +7,7 @@ import Permissions from '../libs/Permissions';
import NewChatPage from './NewChatPage';
import WorkspaceNewRoomPage from './workspace/WorkspaceNewRoomPage';
import CONST from '../CONST';
+import ONYXKEYS from '../ONYXKEYS';
import withWindowDimensions, {windowDimensionsPropTypes} from '../components/withWindowDimensions';
import HeaderWithBackButton from '../components/HeaderWithBackButton';
import ScreenWrapper from '../components/ScreenWrapper';
@@ -66,4 +68,10 @@ NewChatSelectorPage.propTypes = propTypes;
NewChatSelectorPage.defaultProps = defaultProps;
NewChatSelectorPage.displayName = 'NewChatPage';
-export default compose(withLocalize, withWindowDimensions)(NewChatSelectorPage);
+export default compose(
+ withLocalize,
+ withWindowDimensions,
+ withOnyx({
+ betas: {key: ONYXKEYS.BETAS},
+ }),
+)(NewChatSelectorPage);
diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js
index a81d02c60b36..a36149a5f4fa 100644
--- a/src/pages/ShareCodePage.js
+++ b/src/pages/ShareCodePage.js
@@ -42,8 +42,9 @@ class ShareCodePage extends React.Component {
render() {
const isReport = this.props.report != null && this.props.report.reportID != null;
- const subtitle = ReportUtils.getChatRoomSubtitle(this.props.report);
-
+ const title = isReport ? ReportUtils.getReportName(this.props.report) : this.props.currentUserPersonalDetails.displayName;
+ const formattedEmail = this.props.formatPhoneNumber(this.props.session.email);
+ const subtitle = isReport ? ReportUtils.getParentNavigationSubtitle(this.props.report).workspaceName || ReportUtils.getChatRoomSubtitle(this.props.report) : formattedEmail;
const urlWithTrailingSlash = Url.addTrailingForwardSlash(this.props.environmentURL);
const url = isReport
? `${urlWithTrailingSlash}${ROUTES.getReportRoute(this.props.report.reportID)}`
@@ -51,7 +52,6 @@ class ShareCodePage extends React.Component {
const platform = getPlatform();
const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID;
- const formattedEmail = this.props.formatPhoneNumber(this.props.session.email);
return (
@@ -65,8 +65,8 @@ class ShareCodePage extends React.Component {
Navigation.goBack(ROUTES.HOME)}
- backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[ROUTES.I_KNOW_A_TEACHER]}
illustration={LottieAnimations.SaveTheWorld}
>
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
index 0257590908e8..ccf7a0a51518 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
@@ -246,6 +246,11 @@ function ComposerWithSuggestions({
return '';
}
+ // Since we're submitting the form here which should clear the composer
+ // We don't really care about saving the draft the user was typing
+ // We need to make sure an empty draft gets saved instead
+ debouncedSaveReportComment.cancel();
+
updateComment('');
setTextInputShouldClear(true);
if (isComposerFullSize) {
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
index ddcd43cd8cd0..c5179290bf2c 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
@@ -30,7 +30,6 @@ import OfflineWithFeedback from '../../../../components/OfflineWithFeedback';
import SendButton from './SendButton';
import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems';
import ComposerWithSuggestions from './ComposerWithSuggestions';
-import debouncedSaveReportComment from '../../../../libs/ComposerUtils/debouncedSaveReportComment';
import reportActionPropTypes from '../reportActionPropTypes';
import useLocalize from '../../../../hooks/useLocalize';
import getModalState from '../../../../libs/getModalState';
@@ -220,10 +219,6 @@ function ReportActionCompose({
*/
const addAttachment = useCallback(
(file) => {
- // Since we're submitting the form here which should clear the composer
- // We don't really care about saving the draft the user was typing
- // We need to make sure an empty draft gets saved instead
- debouncedSaveReportComment.cancel();
const newComment = composerRef.current.prepareCommentAndResetComposer();
Report.addAttachment(reportID, file, newComment);
setTextInputShouldClear(false);
@@ -251,11 +246,6 @@ function ReportActionCompose({
e.preventDefault();
}
- // Since we're submitting the form here which should clear the composer
- // We don't really care about saving the draft the user was typing
- // We need to make sure an empty draft gets saved instead
- debouncedSaveReportComment.cancel();
-
const newComment = composerRef.current.prepareCommentAndResetComposer();
if (!newComment) {
return;
diff --git a/src/pages/home/sidebar/SidebarScreen/index.js b/src/pages/home/sidebar/SidebarScreen/index.js
index 705d9d1e2d08..08888352ddff 100755
--- a/src/pages/home/sidebar/SidebarScreen/index.js
+++ b/src/pages/home/sidebar/SidebarScreen/index.js
@@ -23,18 +23,28 @@ function SidebarScreen(props) {
}, []),
);
+ /**
+ * Method to hide popover when dragover.
+ */
+ const hidePopoverOnDragOver = useCallback(() => {
+ if (!popoverModal.current) {
+ return;
+ }
+ popoverModal.current.hideCreateMenu();
+ }, []);
+
/**
* Method create event listener
*/
const createDragoverListener = () => {
- document.addEventListener('dragover', () => popoverModal.current.hideCreateMenu());
+ document.addEventListener('dragover', hidePopoverOnDragOver);
};
/**
* Method remove event listener.
*/
const removeDragoverListener = () => {
- document.removeEventListener('dragover', () => popoverModal.current.hideCreateMenu());
+ document.removeEventListener('dragover', hidePopoverOnDragOver);
};
return (
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index 7a08a8261cbf..1238d8934f75 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -104,7 +104,7 @@ function IOUCurrencySelection(props) {
};
});
- const searchRegex = new RegExp(Str.escapeForRegExp(searchValue), 'i');
+ const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim()), 'i');
const filteredCurrencies = _.filter(currencyOptions, (currencyOption) => searchRegex.test(currencyOption.text));
const isEmpty = searchValue.trim() && !filteredCurrencies.length;
diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js
index 68e85a34746d..e34730acccea 100644
--- a/src/pages/iou/WaypointEditor.js
+++ b/src/pages/iou/WaypointEditor.js
@@ -23,6 +23,7 @@ import * as Transaction from '../../libs/actions/Transaction';
import * as ValidationUtils from '../../libs/ValidationUtils';
import ROUTES from '../../ROUTES';
import transactionPropTypes from '../../components/transactionPropTypes';
+import * as ErrorUtils from '../../libs/ErrorUtils';
const propTypes = {
/** The transactionID of the IOU */
@@ -104,18 +105,28 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI
const errors = {};
const waypointValue = values[`waypoint${waypointIndex}`] || '';
if (isOffline && waypointValue !== '' && !ValidationUtils.isValidAddress(waypointValue)) {
- errors[`waypoint${waypointIndex}`] = 'bankAccount.error.address';
+ ErrorUtils.addErrorMessage(errors, `waypoint${waypointIndex}`, 'bankAccount.error.address');
}
// If the user is online and they are trying to save a value without using the autocomplete, show an error message instructing them to use a selected address instead.
// That enables us to save the address with coordinates when it is selected
if (!isOffline && waypointValue !== '' && waypointAddress !== waypointValue) {
- errors[`waypoint${waypointIndex}`] = 'distance.errors.selectSuggestedAddress';
+ ErrorUtils.addErrorMessage(errors, `waypoint${waypointIndex}`, 'distance.errors.selectSuggestedAddress');
}
return errors;
};
+ const saveWaypoint = (waypoint) => {
+ if (parsedWaypointIndex < _.size(allWaypoints)) {
+ Transaction.saveWaypoint(transactionID, waypointIndex, waypoint);
+ } else {
+ const finishWaypoint = lodashGet(allWaypoints, `waypoint${_.size(allWaypoints) - 1}`, {});
+ Transaction.saveWaypoint(transactionID, waypointIndex, finishWaypoint);
+ Transaction.saveWaypoint(transactionID, waypointIndex - 1, waypoint);
+ }
+ };
+
const onSubmit = (values) => {
const waypointValue = values[`waypoint${waypointIndex}`] || '';
@@ -132,8 +143,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI
lng: null,
address: waypointValue,
};
-
- Transaction.saveWaypoint(transactionID, waypointIndex, waypoint);
+ saveWaypoint(waypoint);
}
// Other flows will be handled by selecting a waypoint with selectWaypoint as this is mainly for the offline flow
@@ -152,8 +162,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI
lng: values.lng,
address: values.address,
};
-
- Transaction.saveWaypoint(transactionID, waypointIndex, waypoint);
+ saveWaypoint(waypoint);
Navigation.goBack(ROUTES.getMoneyRequestDistanceTabRoute(iouType));
};
@@ -163,7 +172,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI
onEntryTransitionEnd={() => textInput.current && textInput.current.focus()}
shouldEnableMaxHeight
>
- waypointCount - 1) && isFocused}>
+ waypointCount) && isFocused}>
(textInput.current = e)}
- hint={!isOffline ? translate('distance.errors.selectSuggestedAddress') : ''}
+ hint={!isOffline ? 'distance.errors.selectSuggestedAddress' : ''}
containerStyles={[styles.mt4]}
label={translate('distance.address')}
defaultValue={waypointAddress}
diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js
index 1ea0b002b235..e08fd5bde881 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.js
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.js
@@ -12,6 +12,7 @@ import * as DeviceCapabilities from '../../../libs/DeviceCapabilities';
import TextInputWithCurrencySymbol from '../../../components/TextInputWithCurrencySymbol';
import useLocalize from '../../../hooks/useLocalize';
import CONST from '../../../CONST';
+import FormHelpMessage from '../../../components/FormHelpMessage';
import refPropTypes from '../../../components/refPropTypes';
import getOperatingSystem from '../../../libs/getOperatingSystem';
import * as Browser from '../../../libs/Browser';
@@ -57,6 +58,8 @@ const getNewSelection = (oldSelection, prevLength, newLength) => {
return {start: cursorPosition, end: cursorPosition};
};
+const isAmountValid = (amount) => !amount.length || parseFloat(amount) < 0.01;
+
const AMOUNT_VIEW_ID = 'amountView';
const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView';
const NUM_PAD_VIEW_ID = 'numPadView';
@@ -70,6 +73,9 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : '';
const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString);
+ const [isInvalidAmount, setIsInvalidAmount] = useState(isAmountValid(selectedAmountAsString));
+ const [firstPress, setFirstPress] = useState(false);
+ const [formError, setFormError] = useState('');
const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true);
const [selection, setSelection] = useState({
@@ -127,6 +133,9 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
setSelection((prevSelection) => ({...prevSelection}));
return;
}
+ const checkInvalidAmount = isAmountValid(newAmountWithoutSpaces);
+ setIsInvalidAmount(checkInvalidAmount);
+ setFormError(checkInvalidAmount ? 'iou.error.invalidAmount' : '');
setCurrentAmount((prevAmount) => {
const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces);
const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current;
@@ -177,8 +186,13 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
* Submit amount and navigate to a proper page
*/
const submitAndNavigateToNextPage = useCallback(() => {
+ if (isInvalidAmount) {
+ setFirstPress(true);
+ setFormError('iou.error.invalidAmount');
+ return;
+ }
onSubmitButtonPress(currentAmount);
- }, [onSubmitButtonPress, currentAmount]);
+ }, [onSubmitButtonPress, currentAmount, isInvalidAmount]);
/**
* Input handler to check for a forward-delete key (or keyboard shortcut) press.
@@ -231,9 +245,16 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
onKeyPress={textInputKeyPress}
/>
+ {!_.isEmpty(formError) && firstPress && (
+
+ )}
onMouseDown(event, [NUM_PAD_CONTAINER_VIEW_ID, NUM_PAD_VIEW_ID])}
- style={[styles.w100, styles.justifyContentEnd, styles.pageWrapper]}
+ style={[styles.w100, styles.justifyContentEnd, styles.pageWrapper, styles.pt0]}
nativeID={NUM_PAD_CONTAINER_VIEW_ID}
>
{DeviceCapabilities.canUseTouchScreen() ? (
@@ -249,7 +270,6 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
style={[styles.w100, styles.mt5]}
onPress={submitAndNavigateToNextPage}
pressOnEnter
- isDisabled={!currentAmount.length || parseFloat(currentAmount) < 0.01}
text={buttonText}
/>
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
index 77ead4bf5a85..a8da1b4ec9ed 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
@@ -158,7 +158,9 @@ function MoneyRequestParticipantsSelector({
* @param {Object} option
*/
const addSingleParticipant = (option) => {
- onAddParticipants([{accountID: option.accountID, login: option.login, isPolicyExpenseChat: option.isPolicyExpenseChat, reportID: option.reportID, selected: true}]);
+ onAddParticipants([
+ {accountID: option.accountID, login: option.login, isPolicyExpenseChat: option.isPolicyExpenseChat, reportID: option.reportID, selected: true, searchText: option.searchText},
+ ]);
navigateToRequest();
};
@@ -187,7 +189,14 @@ function MoneyRequestParticipantsSelector({
} else {
newSelectedOptions = [
...participants,
- {accountID: option.accountID, login: option.login, isPolicyExpenseChat: option.isPolicyExpenseChat, reportID: option.reportID, selected: true},
+ {
+ accountID: option.accountID,
+ login: option.login,
+ isPolicyExpenseChat: option.isPolicyExpenseChat,
+ reportID: option.reportID,
+ selected: true,
+ searchText: option.searchText,
+ },
];
}
@@ -223,7 +232,7 @@ function MoneyRequestParticipantsSelector({
Boolean(newChatOptions.userToInvite),
searchTerm.trim(),
maxParticipantsReached,
- _.some(participants, (participant) => participant.login.toLowerCase().includes(searchTerm.trim().toLowerCase())),
+ _.some(participants, (participant) => participant.searchText.toLowerCase().includes(searchTerm.trim().toLowerCase())),
);
const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails);
diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js
index e703c7f2f24f..6e44d8d57e32 100644
--- a/src/pages/iou/steps/NewRequestAmountPage.js
+++ b/src/pages/iou/steps/NewRequestAmountPage.js
@@ -67,7 +67,7 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) {
const currentCurrency = lodashGet(route, 'params.currency', '');
const isDistanceRequestTab = MoneyRequestUtils.isDistanceRequest(iouType, selectedTab);
- const currency = currentCurrency || iou.currency;
+ const currency = CurrencyUtils.isValidCurrencyCode(currentCurrency) ? currentCurrency : iou.currency;
const focusTextInput = () => {
// Component may not be initialized due to navigation transitions
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index a67e7cbc122e..d10779210b09 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -1,6 +1,6 @@
import lodashGet from 'lodash/get';
import React, {useState, useEffect, useRef, useMemo, useCallback} from 'react';
-import {View, ScrollView} from 'react-native';
+import {View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
import {withOnyx} from 'react-native-onyx';
@@ -12,11 +12,11 @@ import * as Session from '../../libs/actions/Session';
import ONYXKEYS from '../../ONYXKEYS';
import Tooltip from '../../components/Tooltip';
import Avatar from '../../components/Avatar';
-import HeaderWithBackButton from '../../components/HeaderWithBackButton';
import Navigation from '../../libs/Navigation/Navigation';
import * as Expensicons from '../../components/Icon/Expensicons';
-import ScreenWrapper from '../../components/ScreenWrapper';
import MenuItem from '../../components/MenuItem';
+import themeColors from '../../styles/themes/default';
+import SCREENS from '../../SCREENS';
import ROUTES from '../../ROUTES';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import compose from '../../libs/compose';
@@ -43,6 +43,7 @@ import PressableWithoutFeedback from '../../components/Pressable/PressableWithou
import useLocalize from '../../hooks/useLocalize';
import useSingleExecution from '../../hooks/useSingleExecution';
import useWaitForNavigation from '../../hooks/useWaitForNavigation';
+import HeaderPageLayout from '../../components/HeaderPageLayout';
const propTypes = {
/* Onyx Props */
@@ -326,79 +327,80 @@ function InitialSettingsPage(props) {
if (_.isEmpty(props.currentUserPersonalDetails)) {
return null;
}
-
- return (
-
- {({safeAreaPaddingBottomStyle}) => (
+ const headerContent = (
+
+ {_.isEmpty(props.currentUserPersonalDetails) || _.isUndefined(props.currentUserPersonalDetails.displayName) ? (
+
+ ) : (
<>
-
-
+
+
+
+
+
+
+
-
- {_.isEmpty(props.currentUserPersonalDetails) || _.isUndefined(props.currentUserPersonalDetails.displayName) ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
- {props.currentUserPersonalDetails.displayName ? props.currentUserPersonalDetails.displayName : props.formatPhoneNumber(props.session.email)}
-
-
-
- {Boolean(props.currentUserPersonalDetails.displayName) && (
-
- {props.formatPhoneNumber(props.session.email)}
-
- )}
-
- )}
- {getMenuItems}
-
- signOut(true)}
- onCancel={() => toggleSignoutConfirmModal(false)}
- />
-
-
+
+
+ {props.currentUserPersonalDetails.displayName ? props.currentUserPersonalDetails.displayName : props.formatPhoneNumber(props.session.email)}
+
+
+
+ {Boolean(props.currentUserPersonalDetails.displayName) && (
+
+ {props.formatPhoneNumber(props.session.email)}
+
+ )}
>
)}
-
+
+ );
+
+ return (
+
+
+ {getMenuItems}
+ signOut(true)}
+ onCancel={() => toggleSignoutConfirmModal(false)}
+ />
+
+
);
}
diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js
index dcd356978bd3..807bd73cecc1 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusPage.js
+++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js
@@ -4,7 +4,7 @@ import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../../../../components/withCurrentUserPersonalDetails';
import MenuItemWithTopDescription from '../../../../components/MenuItemWithTopDescription';
-import StaticHeaderPageLayout from '../../../../components/StaticHeaderPageLayout';
+import HeaderPageLayout from '../../../../components/HeaderPageLayout';
import * as Expensicons from '../../../../components/Icon/Expensicons';
import withLocalize from '../../../../components/withLocalize';
import MenuItem from '../../../../components/MenuItem';
@@ -19,6 +19,7 @@ import styles from '../../../../styles/styles';
import compose from '../../../../libs/compose';
import ONYXKEYS from '../../../../ONYXKEYS';
import ROUTES from '../../../../ROUTES';
+import SCREENS from '../../../../SCREENS';
const propTypes = {
...withCurrentUserPersonalDetailsPropTypes,
@@ -63,11 +64,17 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
useEffect(() => () => User.clearDraftCustomStatus(), []);
return (
-
+ }
+ headerContainerStyles={[styles.staticHeaderImage]}
+ backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.STATUS]}
footer={footerComponent}
>
@@ -91,7 +98,7 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
wrapperStyle={[styles.cardMenuItem]}
/>
)}
-
+
);
}
diff --git a/src/pages/settings/Security/SecuritySettingsPage.js b/src/pages/settings/Security/SecuritySettingsPage.js
index 7f08247557f4..293e488ede7a 100644
--- a/src/pages/settings/Security/SecuritySettingsPage.js
+++ b/src/pages/settings/Security/SecuritySettingsPage.js
@@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
+import SCREENS from '../../../SCREENS';
import styles from '../../../styles/styles';
import * as Expensicons from '../../../components/Icon/Expensicons';
import themeColors from '../../../styles/themes/default';
@@ -54,7 +55,7 @@ function SecuritySettingsPage(props) {
shouldShowBackButton
shouldShowCloseButton
illustration={LottieAnimations.Safe}
- backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[ROUTES.SETTINGS_SECURITY]}
+ backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.SECURITY]}
>
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js
index 52d7a9806f69..7aa7a8ab64c1 100644
--- a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js
@@ -1,4 +1,4 @@
-import React, {useEffect} from 'react';
+import React, {useEffect, useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import {ActivityIndicator, View} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';
@@ -23,10 +23,12 @@ import useWindowDimensions from '../../../../../hooks/useWindowDimensions';
import StepWrapper from '../StepWrapper/StepWrapper';
import {defaultAccount, TwoFactorAuthPropTypes} from '../TwoFactorAuthPropTypes';
import * as TwoFactorAuthActions from '../../../../../libs/actions/TwoFactorAuthActions';
+import FormHelpMessage from '../../../../../components/FormHelpMessage';
function CodesStep({account = defaultAccount}) {
const {translate} = useLocalize();
const {isExtraSmallScreenWidth, isSmallScreenWidth} = useWindowDimensions();
+ const [error, setError] = useState('');
const {setStep} = useTwoFactorAuthContext();
@@ -83,6 +85,7 @@ function CodesStep({account = defaultAccount}) {
inline={false}
onPress={() => {
Clipboard.setString(account.recoveryCodes);
+ setError('');
TwoFactorAuthActions.setCodesAreCopied();
}}
styles={[styles.button, styles.buttonMedium, styles.twoFactorAuthCodesButton]}
@@ -93,6 +96,7 @@ function CodesStep({account = defaultAccount}) {
icon={Expensicons.Download}
onPress={() => {
localFileDownload('two-factor-auth-codes', account.recoveryCodes);
+ setError('');
TwoFactorAuthActions.setCodesAreCopied();
}}
inline={false}
@@ -106,11 +110,23 @@ function CodesStep({account = defaultAccount}) {
+ {!_.isEmpty(error) && (
+
+ )}
diff --git a/src/pages/tasks/NewTaskPage.js b/src/pages/tasks/NewTaskPage.js
index 9fb600e40753..e4aa0bcb08e1 100644
--- a/src/pages/tasks/NewTaskPage.js
+++ b/src/pages/tasks/NewTaskPage.js
@@ -126,7 +126,15 @@ function NewTaskPage(props) {
return;
}
- Task.createTaskAndNavigate(parentReport.reportID, props.task.title, props.task.description, props.task.assignee, props.task.assigneeAccountID, props.task.assigneeChatReport);
+ Task.createTaskAndNavigate(
+ parentReport.reportID,
+ props.task.title,
+ props.task.description,
+ props.task.assignee,
+ props.task.assigneeAccountID,
+ props.task.assigneeChatReport,
+ parentReport.policyID,
+ );
}
if (!Permissions.canUseTasks(props.betas)) {
diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts
index ec06bb07c3fe..11ade02220ab 100644
--- a/src/styles/StyleUtils.ts
+++ b/src/styles/StyleUtils.ts
@@ -25,6 +25,7 @@ type AvatarSizeValue = ValueOf<
| 'avatarSizeSubscript'
| 'avatarSizeSmall'
| 'avatarSizeSmaller'
+ | 'avatarSizeXLarge'
| 'avatarSizeLarge'
| 'avatarSizeMedium'
| 'avatarSizeLargeBordered'
@@ -95,6 +96,7 @@ const avatarBorderSizes: Partial> = {
[CONST.AVATAR_SIZE.DEFAULT]: variables.componentBorderRadiusNormal,
[CONST.AVATAR_SIZE.MEDIUM]: variables.componentBorderRadiusLarge,
[CONST.AVATAR_SIZE.LARGE]: variables.componentBorderRadiusLarge,
+ [CONST.AVATAR_SIZE.XLARGE]: variables.componentBorderRadiusLarge,
[CONST.AVATAR_SIZE.LARGE_BORDERED]: variables.componentBorderRadiusRounded,
[CONST.AVATAR_SIZE.SMALL_NORMAL]: variables.componentBorderRadiusMedium,
};
@@ -107,6 +109,7 @@ const avatarSizes: Record = {
[CONST.AVATAR_SIZE.SMALL]: variables.avatarSizeSmall,
[CONST.AVATAR_SIZE.SMALLER]: variables.avatarSizeSmaller,
[CONST.AVATAR_SIZE.LARGE]: variables.avatarSizeLarge,
+ [CONST.AVATAR_SIZE.XLARGE]: variables.avatarSizeXLarge,
[CONST.AVATAR_SIZE.MEDIUM]: variables.avatarSizeMedium,
[CONST.AVATAR_SIZE.LARGE_BORDERED]: variables.avatarSizeLargeBordered,
[CONST.AVATAR_SIZE.HEADER]: variables.avatarSizeHeader,
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 01689a43952a..4a2472913fd2 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -879,7 +879,8 @@ const styles = (theme) => ({
offlineIndicatorMobile: {
paddingLeft: 20,
paddingTop: 5,
- paddingBottom: 5,
+ paddingBottom: 30,
+ marginBottom: -25,
},
offlineIndicatorRow: {
@@ -1994,6 +1995,11 @@ const styles = (theme) => ({
height: variables.avatarSizeLarge,
},
+ avatarXLarge: {
+ width: variables.avatarSizeXLarge,
+ height: variables.avatarSizeXLarge,
+ },
+
avatarNormal: {
height: variables.componentSizeNormal,
width: variables.componentSizeNormal,
diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js
index 6abc8d7e4463..5ff997684304 100644
--- a/src/styles/themes/default.js
+++ b/src/styles/themes/default.js
@@ -87,11 +87,12 @@ const darkTheme = {
darkTheme.PAGE_BACKGROUND_COLORS = {
[SCREENS.HOME]: darkTheme.sidebar,
+ [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800,
[SCREENS.SETTINGS.PREFERENCES]: colors.blue500,
[SCREENS.SETTINGS.WORKSPACES]: colors.pink800,
- [ROUTES.SETTINGS_STATUS]: colors.green700,
- [ROUTES.I_KNOW_A_TEACHER]: colors.tangerine800,
- [ROUTES.SETTINGS_SECURITY]: colors.ice500,
+ [SCREENS.SETTINGS.SECURITY]: colors.ice500,
+ [SCREENS.SETTINGS.STATUS]: colors.green700,
+ [SCREENS.SETTINGS.ROOT]: darkTheme.sidebar,
};
export default darkTheme;
diff --git a/src/styles/themes/light.js b/src/styles/themes/light.js
index 69127e19ae14..e6ca56d248d6 100644
--- a/src/styles/themes/light.js
+++ b/src/styles/themes/light.js
@@ -1,6 +1,5 @@
import colors from '../colors';
import SCREENS from '../../SCREENS';
-import ROUTES from '../../ROUTES';
const lightTheme = {
// Figma keys
@@ -86,11 +85,12 @@ const lightTheme = {
lightTheme.PAGE_BACKGROUND_COLORS = {
[SCREENS.HOME]: lightTheme.sidebar,
+ [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800,
[SCREENS.SETTINGS.PREFERENCES]: colors.blue500,
[SCREENS.SETTINGS.WORKSPACES]: colors.pink800,
- [ROUTES.SETTINGS_STATUS]: colors.green700,
- [ROUTES.I_KNOW_A_TEACHER]: colors.tangerine800,
- [ROUTES.SETTINGS_SECURITY]: colors.ice500,
+ [SCREENS.SETTINGS.SECURITY]: colors.ice500,
+ [SCREENS.SETTINGS.STATUS]: colors.green700,
+ [SCREENS.SETTINGS.ROOT]: lightTheme.sidebar,
};
export default lightTheme;
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 6291323cef0b..c75d03a39986 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -28,6 +28,7 @@ export default {
appModalAppIconSize: 48,
buttonBorderRadius: 100,
avatarSizeLargeBordered: 88,
+ avatarSizeXLarge: 120,
avatarSizeLarge: 80,
avatarSizeMedium: 52,
avatarSizeHeader: 40,
diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts
index 5f0072959917..ec505a7e8d07 100644
--- a/src/types/onyx/ReportAction.ts
+++ b/src/types/onyx/ReportAction.ts
@@ -46,6 +46,9 @@ type ReportActionBase = {
/** The ID of the reportAction. It is the string representation of the a 64-bit integer. */
reportActionID?: string;
+ /** The ID of the previous reportAction on the report. It is a string represenation of a 64-bit integer (or null for CREATED actions). */
+ previousReportActionID?: string;
+
actorAccountID?: number;
/** Person who created the action */
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index de128d85e6b1..ea0b178444b5 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -19,25 +19,27 @@ type Route = {
type Routes = Record;
type Transaction = {
- transactionID: string;
amount: number;
category: string;
- currency: string;
- reportID: string;
comment: Comment;
- merchant: string;
created: string;
- pendingAction: OnyxCommon.PendingAction;
+ currency: string;
errors: OnyxCommon.Errors;
+ // The name of the file used for a receipt (formerly receiptFilename)
+ filename?: string;
+ merchant: string;
modifiedAmount?: number;
modifiedCreated?: string;
modifiedCurrency?: string;
+ pendingAction: OnyxCommon.PendingAction;
receipt: {
receiptID?: number;
source?: string;
state?: ValueOf;
};
+ reportID: string;
routes?: Routes;
+ transactionID: string;
tag: string;
};
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index 01c2b4711ce7..7372bb76b9e5 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -11,6 +11,19 @@ import * as ReportActions from '../../src/libs/actions/ReportActions';
import * as Report from '../../src/libs/actions/Report';
import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager';
import waitForNetworkPromises from '../utils/waitForNetworkPromises';
+import * as ReportUtils from '../../src/libs/ReportUtils';
+import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils';
+import * as PersonalDetailsUtils from '../../src/libs/PersonalDetailsUtils';
+import * as User from '../../src/libs/actions/User';
+import PusherHelper from '../utils/PusherHelper';
+import Navigation from '../../src/libs/Navigation/Navigation';
+import ROUTES from '../../src/ROUTES';
+
+jest.mock('../../src/libs/Navigation/Navigation', () => ({
+ navigate: jest.fn(),
+ dismissModal: jest.fn(),
+ goBack: jest.fn(),
+}));
const CARLOS_EMAIL = 'cmartins@expensifail.com';
const CARLOS_ACCOUNT_ID = 1;
@@ -1376,4 +1389,773 @@ describe('actions/IOU', () => {
);
});
});
+
+ describe('deleteMoneyRequest', () => {
+ const amount = 10000;
+ const comment = 'Send me money please';
+ let chatReport;
+ let iouReport;
+ let createIOUAction;
+ let transaction;
+ let thread;
+ const TEST_USER_ACCOUNT_ID = 1;
+ const TEST_USER_LOGIN = 'test@test.com';
+ let IOU_REPORT_ID;
+ let reportActionID;
+ const REPORT_ACTION = {
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
+ actorAccountID: TEST_USER_ACCOUNT_ID,
+ automatic: false,
+ avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_3.png',
+ message: [{type: 'COMMENT', html: 'Testing a comment', text: 'Testing a comment', translationKey: ''}],
+ person: [{type: 'TEXT', style: 'strong', text: 'Test User'}],
+ shouldShow: true,
+ };
+
+ let reportActions;
+
+ beforeEach(async () => {
+ // Given mocks are cleared and helpers are set up
+ jest.clearAllMocks();
+ PusherHelper.setup();
+
+ // Given a test user is signed in with Onyx setup and some initial data
+ await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN);
+ User.subscribeToUserEvents();
+ await waitForBatchedUpdates();
+ await TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID);
+
+ // When an IOU request for money is made
+ IOU.requestMoney({}, amount, CONST.CURRENCY.USD, '', '', TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID, {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, comment);
+ await waitForBatchedUpdates();
+
+ // When fetching all reports from Onyx
+ const allReports = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (reports) => {
+ Onyx.disconnect(connectionID);
+ resolve(reports);
+ },
+ });
+ });
+
+ // Then we should have exactly 2 reports
+ expect(_.size(allReports)).toBe(2);
+
+ // Then one of them should be a chat report with relevant properties
+ chatReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.CHAT);
+ expect(chatReport).toBeTruthy();
+ expect(chatReport).toHaveProperty('reportID');
+ expect(chatReport).toHaveProperty('iouReportID');
+ expect(chatReport.hasOutstandingIOU).toBe(true);
+
+ // Then one of them should be an IOU report with relevant properties
+ iouReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.IOU);
+ expect(iouReport).toBeTruthy();
+ expect(iouReport).toHaveProperty('reportID');
+ expect(iouReport).toHaveProperty('chatReportID');
+
+ // Then their IDs should reference each other
+ expect(chatReport.iouReportID).toBe(iouReport.reportID);
+ expect(iouReport.chatReportID).toBe(chatReport.reportID);
+
+ // Storing IOU Report ID for further reference
+ IOU_REPORT_ID = chatReport.iouReportID;
+
+ await waitForBatchedUpdates();
+
+ // When fetching all report actions from Onyx
+ const allReportActions = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ waitForCollectionCallback: true,
+ callback: (actions) => {
+ Onyx.disconnect(connectionID);
+ resolve(actions);
+ },
+ });
+ });
+
+ // Then we should find an IOU action with specific properties
+ const reportActionsForIOUReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.iouReportID}`];
+ createIOUAction = _.find(reportActionsForIOUReport, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.IOU);
+ expect(createIOUAction).toBeTruthy();
+ expect(createIOUAction.originalMessage.IOUReportID).toBe(iouReport.reportID);
+
+ // When fetching all transactions from Onyx
+ const allTransactions = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ waitForCollectionCallback: true,
+ callback: (transactions) => {
+ Onyx.disconnect(connectionID);
+ resolve(transactions);
+ },
+ });
+ });
+
+ // Then we should find a specific transaction with relevant properties
+ transaction = _.find(allTransactions, (t) => t);
+ expect(transaction).toBeTruthy();
+ expect(transaction.amount).toBe(amount);
+ expect(transaction.reportID).toBe(iouReport.reportID);
+ expect(createIOUAction.originalMessage.IOUTransactionID).toBe(transaction.transactionID);
+ });
+
+ afterEach(PusherHelper.teardown);
+
+ it('delete a money request (IOU Action and transaction) successfully', async () => {
+ // Given the fetch operations are paused and a money request is initiated
+ fetch.pause();
+
+ // When the money request is deleted
+ IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, true);
+ await waitForBatchedUpdates();
+
+ // Then we check if the IOU report action is removed from the report actions collection
+ let reportActionsForReport = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (actionsForReport) => {
+ Onyx.disconnect(connectionID);
+ resolve(actionsForReport);
+ },
+ });
+ });
+
+ createIOUAction = _.find(reportActionsForReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU);
+ expect(createIOUAction).toBeFalsy();
+
+ // Then we check if the transaction is removed from the transactions collection
+ const t = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
+ waitForCollectionCallback: true,
+ callback: (transactionResult) => {
+ Onyx.disconnect(connectionID);
+ resolve(transactionResult);
+ },
+ });
+ });
+
+ expect(t).toBeFalsy();
+
+ // Given fetch operations are resumed
+ fetch.resume();
+
+ // Then we recheck the IOU report action from the report actions collection
+ reportActionsForReport = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (actionsForReport) => {
+ Onyx.disconnect(connectionID);
+ resolve(actionsForReport);
+ },
+ });
+ });
+
+ createIOUAction = _.find(reportActionsForReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU);
+ expect(createIOUAction).toBeFalsy();
+
+ // Then we recheck the transaction from the transactions collection
+ const tr = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
+ waitForCollectionCallback: true,
+ callback: (transactionResult) => {
+ Onyx.disconnect(connectionID);
+ resolve(transactionResult);
+ },
+ });
+ });
+
+ expect(tr).toBeFalsy();
+ });
+
+ it('delete the IOU report when there are no visible comments left in the IOU report', async () => {
+ // Given an IOU report and a paused fetch state
+ fetch.pause();
+
+ // When the IOU money request is deleted
+ IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, true);
+ await waitForBatchedUpdates();
+
+ let report = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (res) => {
+ Onyx.disconnect(connectionID);
+ resolve(res);
+ },
+ });
+ });
+
+ // Then the report should be falsy (indicating deletion)
+ expect(report).toBeFalsy();
+
+ // Given the resumed fetch state
+ fetch.resume();
+
+ report = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (res) => {
+ Onyx.disconnect(connectionID);
+ resolve(res);
+ },
+ });
+ });
+
+ // Then the report should still be falsy (confirming deletion persisted)
+ expect(report).toBeFalsy();
+ });
+
+ it('does not delete the IOU report when there are visible comments left in the IOU report', async () => {
+ // Given the initial setup is completed
+ await waitForBatchedUpdates();
+
+ Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`,
+ callback: (val) => (reportActions = val),
+ });
+ await waitForBatchedUpdates();
+
+ jest.advanceTimersByTime(10);
+
+ // When a comment is added to the IOU report
+ Report.addComment(IOU_REPORT_ID, 'Testing a comment');
+ await waitForBatchedUpdates();
+
+ // Then verify that the comment is correctly added
+ const resultAction = _.find(reportActions, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT);
+ reportActionID = resultAction.reportActionID;
+
+ expect(resultAction.message).toEqual(REPORT_ACTION.message);
+ expect(resultAction.person).toEqual(REPORT_ACTION.person);
+ expect(resultAction.pendingAction).toBeNull();
+
+ await waitForBatchedUpdates();
+
+ // Verify there are three actions (created + iou + addcomment) and our optimistic comment has been removed
+ expect(_.size(reportActions)).toBe(3);
+
+ // Then check the loading state of our action
+ const resultActionAfterUpdate = reportActions[reportActionID];
+ expect(resultActionAfterUpdate.pendingAction).toBeNull();
+
+ // When we attempt to delete a money request from the IOU report
+ fetch.pause();
+ IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, false);
+ await waitForBatchedUpdates();
+
+ // Then expect that the IOU report still exists
+ let allReports = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (reports) => {
+ Onyx.disconnect(connectionID);
+ resolve(reports);
+ },
+ });
+ });
+
+ await waitForBatchedUpdates();
+
+ iouReport = _.find(allReports, (report) => ReportUtils.isIOUReport(report));
+ expect(iouReport).toBeTruthy();
+ expect(iouReport).toHaveProperty('reportID');
+ expect(iouReport).toHaveProperty('chatReportID');
+
+ // Given the resumed fetch state
+ fetch.resume();
+
+ allReports = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (reports) => {
+ Onyx.disconnect(connectionID);
+ resolve(reports);
+ },
+ });
+ });
+ // Then expect that the IOU report still exists
+ iouReport = _.find(allReports, (report) => ReportUtils.isIOUReport(report));
+ expect(iouReport).toBeTruthy();
+ expect(iouReport).toHaveProperty('reportID');
+ expect(iouReport).toHaveProperty('chatReportID');
+ });
+
+ it('delete the transaction thread if there are no visible comments in the thread', async () => {
+ // Given all promises are resolved
+ await waitForBatchedUpdates();
+ jest.advanceTimersByTime(10);
+
+ // Given a transaction thread
+ thread = ReportUtils.buildTransactionThread(createIOUAction, IOU_REPORT_ID);
+
+ Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`,
+ callback: (val) => (reportActions = val),
+ });
+
+ await waitForBatchedUpdates();
+
+ jest.advanceTimersByTime(10);
+
+ // Given User logins from the participant accounts
+ const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
+
+ // When Opening a thread report with the given details
+ Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
+ await waitForBatchedUpdates();
+
+ // Then The iou action has the transaction report id as a child report ID
+ const allReportActions = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ waitForCollectionCallback: true,
+ callback: (actions) => {
+ Onyx.disconnect(connectionID);
+ resolve(actions);
+ },
+ });
+ });
+ const reportActionsForIOUReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.iouReportID}`];
+ createIOUAction = _.find(reportActionsForIOUReport, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.IOU);
+ expect(createIOUAction.childReportID).toBe(thread.reportID);
+
+ await waitForBatchedUpdates();
+
+ // Given Fetch is paused and timers have advanced
+ fetch.pause();
+ jest.advanceTimersByTime(10);
+
+ // When Deleting a money request
+ IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, false);
+ await waitForBatchedUpdates();
+
+ // Then The report for the given thread ID does not exist
+ let report = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (reportData) => {
+ Onyx.disconnect(connectionID);
+ resolve(reportData);
+ },
+ });
+ });
+
+ expect(report).toBeFalsy();
+ fetch.resume();
+
+ // Then After resuming fetch, the report for the given thread ID still does not exist
+ report = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (reportData) => {
+ Onyx.disconnect(connectionID);
+ resolve(reportData);
+ },
+ });
+ });
+
+ expect(report).toBeFalsy();
+ });
+
+ it('does not delete the transaction thread if there are visible comments in the thread', async () => {
+ // Given initial environment is set up
+ await waitForBatchedUpdates();
+
+ // Given a transaction thread
+ thread = ReportUtils.buildTransactionThread(createIOUAction);
+ const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
+ jest.advanceTimersByTime(10);
+ Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
+ await waitForBatchedUpdates();
+
+ Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`,
+ callback: (val) => (reportActions = val),
+ });
+ await waitForBatchedUpdates();
+
+ await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (report) => {
+ Onyx.disconnect(connectionID);
+ expect(report).toBeTruthy();
+ resolve();
+ },
+ });
+ });
+
+ jest.advanceTimersByTime(10);
+
+ // When a comment is added
+ Report.addComment(thread.reportID, 'Testing a comment');
+ await waitForBatchedUpdates();
+
+ // Then comment details should match the expected report action
+ const resultAction = _.find(reportActions, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT);
+ reportActionID = resultAction.reportActionID;
+ expect(resultAction.message).toEqual(REPORT_ACTION.message);
+ expect(resultAction.person).toEqual(REPORT_ACTION.person);
+
+ await waitForBatchedUpdates();
+
+ // Then the report should have 2 actions
+ expect(_.size(reportActions)).toBe(2);
+ const resultActionAfter = reportActions[reportActionID];
+ expect(resultActionAfter.pendingAction).toBeNull();
+
+ fetch.pause();
+ // When deleting money request
+ IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, false);
+ await waitForBatchedUpdates();
+
+ // Then the transaction thread report should still exist
+ await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (report) => {
+ Onyx.disconnect(connectionID);
+ expect(report).toBeTruthy();
+ resolve();
+ },
+ });
+ });
+
+ // When fetch resumes
+ // Then the transaction thread report should still exist
+ fetch.resume();
+ await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (report) => {
+ Onyx.disconnect(connectionID);
+ expect(report).toBeTruthy();
+ resolve();
+ },
+ });
+ });
+ });
+
+ it('update the moneyRequestPreview to show [Deleted request] when appropriate', async () => {
+ await waitForBatchedUpdates();
+
+ // Given a thread report
+
+ jest.advanceTimersByTime(10);
+ thread = ReportUtils.buildTransactionThread(createIOUAction, IOU_REPORT_ID);
+
+ Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`,
+ callback: (val) => (reportActions = val),
+ });
+ await waitForBatchedUpdates();
+
+ jest.advanceTimersByTime(10);
+ const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
+ Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
+
+ await waitForBatchedUpdates();
+
+ const allReportActions = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ waitForCollectionCallback: true,
+ callback: (actions) => {
+ Onyx.disconnect(connectionID);
+ resolve(actions);
+ },
+ });
+ });
+
+ const reportActionsForIOUReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.iouReportID}`];
+ createIOUAction = _.find(reportActionsForIOUReport, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.IOU);
+ expect(createIOUAction.childReportID).toBe(thread.reportID);
+
+ await waitForBatchedUpdates();
+
+ // Given an added comment to the thread report
+
+ jest.advanceTimersByTime(10);
+
+ Report.addComment(thread.reportID, 'Testing a comment');
+ await waitForBatchedUpdates();
+
+ let resultAction = _.find(reportActions, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT);
+ reportActionID = resultAction.reportActionID;
+
+ expect(resultAction.message).toEqual(REPORT_ACTION.message);
+ expect(resultAction.person).toEqual(REPORT_ACTION.person);
+ expect(resultAction.pendingAction).toBeNull();
+
+ await waitForBatchedUpdates();
+
+ // Verify there are three actions (created + addcomment) and our optimistic comment has been removed
+ expect(_.size(reportActions)).toBe(2);
+
+ let resultActionAfterUpdate = reportActions[reportActionID];
+
+ // Verify that our action is no longer in the loading state
+ expect(resultActionAfterUpdate.pendingAction).toBeNull();
+
+ await waitForBatchedUpdates();
+
+ // Given an added comment to the IOU report
+
+ Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`,
+ callback: (val) => (reportActions = val),
+ });
+ await waitForBatchedUpdates();
+
+ jest.advanceTimersByTime(10);
+
+ Report.addComment(IOU_REPORT_ID, 'Testing a comment');
+ await waitForBatchedUpdates();
+
+ resultAction = _.find(reportActions, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT);
+ reportActionID = resultAction.reportActionID;
+
+ expect(resultAction.message).toEqual(REPORT_ACTION.message);
+ expect(resultAction.person).toEqual(REPORT_ACTION.person);
+ expect(resultAction.pendingAction).toBeNull();
+
+ await waitForBatchedUpdates();
+
+ // Verify there are three actions (created + iou + addcomment) and our optimistic comment has been removed
+ expect(_.size(reportActions)).toBe(3);
+
+ resultActionAfterUpdate = reportActions[reportActionID];
+
+ // Verify that our action is no longer in the loading state
+ expect(resultActionAfterUpdate.pendingAction).toBeNull();
+
+ fetch.pause();
+ // When we delete the money request
+ IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, false);
+ await waitForBatchedUpdates();
+
+ // Then we expect the moneyRequestPreview to show [Deleted request]
+
+ await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (reportActionsForReport) => {
+ Onyx.disconnect(connectionID);
+ createIOUAction = _.find(reportActionsForReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU);
+ expect(createIOUAction.message[0].isDeletedParentAction).toBeTruthy();
+ resolve();
+ },
+ });
+ });
+
+ // When we resume fetch
+
+ fetch.resume();
+
+ // Then we expect the moneyRequestPreview to show [Deleted request]
+
+ await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
+ waitForCollectionCallback: true,
+ callback: (reportActionsForReport) => {
+ Onyx.disconnect(connectionID);
+ createIOUAction = _.find(reportActionsForReport, (reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU);
+ expect(createIOUAction.message[0].isDeletedParentAction).toBeTruthy();
+ resolve();
+ },
+ });
+ });
+ });
+
+ it('update IOU report and reportPreview with new totals and messages if the IOU report is not deleted', async () => {
+ await waitForBatchedUpdates();
+ Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
+ callback: (val) => (iouReport = val),
+ });
+ await waitForBatchedUpdates();
+
+ // Given a second money request in addition to the first one
+
+ jest.advanceTimersByTime(10);
+ const amount2 = 20000;
+ const comment2 = 'Send me money please 2';
+ IOU.requestMoney(chatReport, amount2, CONST.CURRENCY.USD, '', '', TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID, {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, comment2);
+
+ await waitForBatchedUpdates();
+
+ // Then we expect the IOU report and reportPreview to update with new totals
+
+ expect(iouReport).toBeTruthy();
+ expect(iouReport).toHaveProperty('reportID');
+ expect(iouReport).toHaveProperty('chatReportID');
+ expect(iouReport.hasOutstandingIOU).toBeTruthy();
+ expect(iouReport.total).toBe(30000);
+
+ const ioupreview = ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID);
+ expect(ioupreview).toBeTruthy();
+ expect(ioupreview.message[0].text).toBe('rory@expensifail.com owes $300.00');
+
+ // When we delete the first money request
+ fetch.pause();
+ jest.advanceTimersByTime(10);
+ IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, false);
+ await waitForBatchedUpdates();
+
+ // Then we expect the IOU report and reportPreview to update with new totals
+
+ expect(iouReport).toBeTruthy();
+ expect(iouReport).toHaveProperty('reportID');
+ expect(iouReport).toHaveProperty('chatReportID');
+ expect(iouReport.hasOutstandingIOU).toBeTruthy();
+ expect(iouReport.total).toBe(20000);
+
+ // When we resume fetch
+ fetch.resume();
+
+ // Then we expect the IOU report and reportPreview to update with new totals
+ expect(iouReport).toBeTruthy();
+ expect(iouReport).toHaveProperty('reportID');
+ expect(iouReport).toHaveProperty('chatReportID');
+ expect(iouReport.hasOutstandingIOU).toBeTruthy();
+ expect(iouReport.total).toBe(20000);
+ });
+
+ it('navigate the user correctly to the iou Report when appropriate', async () => {
+ await waitForBatchedUpdates();
+
+ Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`,
+ callback: (val) => (reportActions = val),
+ });
+ await waitForBatchedUpdates();
+
+ // Given an added comment to the iou report
+
+ jest.advanceTimersByTime(10);
+
+ Report.addComment(IOU_REPORT_ID, 'Testing a comment');
+ await waitForBatchedUpdates();
+
+ const resultAction = _.find(reportActions, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT);
+ reportActionID = resultAction.reportActionID;
+
+ expect(resultAction.message).toEqual(REPORT_ACTION.message);
+ expect(resultAction.person).toEqual(REPORT_ACTION.person);
+
+ await waitForBatchedUpdates();
+
+ // Verify there are three actions (created + iou + addcomment) and our optimistic comment has been removed
+ expect(_.size(reportActions)).toBe(3);
+
+ await waitForBatchedUpdates();
+
+ // Given a thread report
+
+ jest.advanceTimersByTime(10);
+ thread = ReportUtils.buildTransactionThread(createIOUAction, IOU_REPORT_ID);
+
+ Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`,
+ callback: (val) => (reportActions = val),
+ });
+ await waitForBatchedUpdates();
+
+ jest.advanceTimersByTime(10);
+ const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
+ Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
+ await waitForBatchedUpdates();
+
+ const allReportActions = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ waitForCollectionCallback: true,
+ callback: (actions) => {
+ Onyx.disconnect(connectionID);
+ resolve(actions);
+ },
+ });
+ });
+
+ const reportActionsForIOUReport = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.iouReportID}`];
+ createIOUAction = _.find(reportActionsForIOUReport, (ra) => ra.actionName === CONST.REPORT.ACTIONS.TYPE.IOU);
+ expect(createIOUAction.childReportID).toBe(thread.reportID);
+
+ await waitForBatchedUpdates();
+
+ // When we delete the money request in SingleTransactionView and we should not delete the IOU report
+
+ fetch.pause();
+
+ IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, true);
+ await waitForBatchedUpdates();
+
+ let allReports = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (reports) => {
+ Onyx.disconnect(connectionID);
+ resolve(reports);
+ },
+ });
+ });
+
+ await waitForBatchedUpdates();
+
+ iouReport = _.find(allReports, (report) => ReportUtils.isIOUReport(report));
+ expect(iouReport).toBeTruthy();
+ expect(iouReport).toHaveProperty('reportID');
+ expect(iouReport).toHaveProperty('chatReportID');
+
+ fetch.resume();
+
+ allReports = await new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (reports) => {
+ Onyx.disconnect(connectionID);
+ resolve(reports);
+ },
+ });
+ });
+
+ iouReport = _.find(allReports, (report) => ReportUtils.isIOUReport(report));
+ expect(iouReport).toBeTruthy();
+ expect(iouReport).toHaveProperty('reportID');
+ expect(iouReport).toHaveProperty('chatReportID');
+
+ // Then we expect to navigate to the iou report
+
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.getReportRoute(IOU_REPORT_ID));
+ });
+
+ it('navigate the user correctly to the chat Report when appropriate', () => {
+ // When we delete the money request and we should delete the IOU report
+ IOU.deleteMoneyRequest(transaction.transactionID, createIOUAction, false);
+ // Then we expect to navigate to the chat report
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.getReportRoute(chatReport.reportID));
+ });
+ });
});
diff --git a/tsconfig.json b/tsconfig.json
index d138ce81e36c..0c88512b9749 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -32,6 +32,6 @@
"skipLibCheck": true,
"incremental": true
},
- "exclude": ["**/node_modules/*", "**/dist/*", ".github/actions/**/index.js"],
+ "exclude": ["**/node_modules/*", "**/dist/*", ".github/actions/**/index.js", "**/docs/*"],
"include": ["src", "desktop", "web", "docs", "assets", "config", "tests", "jest", "__mocks__", ".github/**/*", ".storybook/**/*"]
}