diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index 1f80908b02b5..0951b194430b 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -24,8 +24,16 @@ jobs:
- name: Check for new JavaScript files
run: |
git fetch origin main --no-tags --depth=1
- count_new_js=$(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js' | wc -l)
+
+ # Explanation:
+ # - comm is used to get the intersection between two bash arrays
+ # - git diff is used to see the files that were added on this branch
+ # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main
+ # - wc counts the words in the result of the intersection
+ count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l)
if [ "$count_new_js" -gt "0" ]; then
echo "ERROR: Found new JavaScript files in the /src/libs, /src/hooks, /src/styles, or /src/languages directories; use TypeScript instead."
exit 1
fi
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/android/app/build.gradle b/android/app/build.gradle
index cf8a3f74e2d0..7e0f7671f561 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -96,8 +96,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001040200
- versionName "1.4.2-0"
+ versionCode 1001040203
+ versionName "1.4.2-3"
}
flavorDimensions "default"
diff --git a/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png
index ca084324808e..8a102375d1c8 100644
Binary files a/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png and b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 24298c4ff064..fb8390ed33da 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.2.0
+ 1.4.2.3
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 519c58663534..555d20bf7323 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.2.0
+ 1.4.2.3
diff --git a/jest.config.js b/jest.config.js
index 9b24cc47438d..645436bfe53a 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -24,7 +24,7 @@ module.exports = {
},
testEnvironment: 'jsdom',
setupFiles: ['/jest/setup.js', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'],
- setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js'],
+ setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js', '/tests/perf-test/setupAfterEnv.js'],
cacheDirectory: '/.jest-cache',
moduleNameMapper: {
'\\.(lottie)$': '/__mocks__/fileMock.js',
diff --git a/package-lock.json b/package-lock.json
index d25a90a79654..46b0c84ac7e9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.2-0",
+ "version": "1.4.2-3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.2-0",
+ "version": "1.4.2-3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -56,7 +56,6 @@
"expo-asset": "~8.10.1",
"expo-image": "^1.8.1",
"fbjs": "^3.0.2",
- "focus-trap-react": "^10.2.1",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-expo": "^49.0.0",
@@ -33151,28 +33150,6 @@
"readable-stream": "^2.3.6"
}
},
- "node_modules/focus-trap": {
- "version": "7.5.2",
- "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz",
- "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==",
- "dependencies": {
- "tabbable": "^6.2.0"
- }
- },
- "node_modules/focus-trap-react": {
- "version": "10.2.1",
- "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz",
- "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==",
- "dependencies": {
- "focus-trap": "^7.5.2",
- "tabbable": "^6.2.0"
- },
- "peerDependencies": {
- "prop-types": "^15.8.1",
- "react": ">=16.3.0",
- "react-dom": ">=16.3.0"
- }
- },
"node_modules/follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
@@ -52453,11 +52430,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
- "node_modules/tabbable": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
- "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
- },
"node_modules/table": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz",
@@ -80493,23 +80465,6 @@
"readable-stream": "^2.3.6"
}
},
- "focus-trap": {
- "version": "7.5.2",
- "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz",
- "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==",
- "requires": {
- "tabbable": "^6.2.0"
- }
- },
- "focus-trap-react": {
- "version": "10.2.1",
- "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz",
- "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==",
- "requires": {
- "focus-trap": "^7.5.2",
- "tabbable": "^6.2.0"
- }
- },
"follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
@@ -94182,11 +94137,6 @@
"version": "2.0.15",
"dev": true
},
- "tabbable": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
- "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
- },
"table": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz",
diff --git a/package.json b/package.json
index 6414e9caa546..5e4d542aa3f4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.2-0",
+ "version": "1.4.2-3",
"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.",
@@ -103,7 +103,6 @@
"expo-asset": "~8.10.1",
"expo-image": "^1.8.1",
"fbjs": "^3.0.2",
- "focus-trap-react": "^10.2.1",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-expo": "^49.0.0",
diff --git a/src/CONST.ts b/src/CONST.ts
index 4024158d0805..436ac4ebbc31 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -574,6 +574,7 @@ const CONST = {
CREATED: 'CREATED',
IOU: 'IOU',
MODIFIEDEXPENSE: 'MODIFIEDEXPENSE',
+ MOVED: 'MOVED',
REIMBURSEMENTQUEUED: 'REIMBURSEMENTQUEUED',
RENAMED: 'RENAMED',
REPORTPREVIEW: 'REPORTPREVIEW',
@@ -1188,7 +1189,8 @@ const CONST = {
PAYMENT_METHODS: {
DEBIT_CARD: 'debitCard',
- BANK_ACCOUNT: 'bankAccount',
+ PERSONAL_BANK_ACCOUNT: 'bankAccount',
+ BUSINESS_BANK_ACCOUNT: 'businessBankAccount',
},
PAYMENT_METHOD_ID_KEYS: {
@@ -1231,6 +1233,7 @@ const CONST = {
DOCX: 'docx',
SVG: 'svg',
},
+ RECEIPT_ERROR: 'receiptError',
},
GROWL: {
@@ -1277,7 +1280,11 @@ const CONST = {
TYPE: {
FREE: 'free',
PERSONAL: 'personal',
+
+ // Often referred to as "control" workspaces
CORPORATE: 'corporate',
+
+ // Often referred to as "collect" workspaces
TEAM: 'team',
},
ROLE: {
@@ -2890,6 +2897,23 @@ const CONST = {
LEARN_MORE_LINK: 'https://help.expensify.com/articles/new-expensify/billing-and-plan-types/Referral-Program',
LINK: 'https://join.my.expensify.com',
},
+
+ /**
+ * native IDs for close buttons in Overlay component
+ */
+ OVERLAY: {
+ TOP_BUTTON_NATIVE_ID: 'overLayTopButton',
+ BOTTOM_BUTTON_NATIVE_ID: 'overLayBottomButton',
+ },
+
+ BACK_BUTTON_NATIVE_ID: 'backButton',
+
+ /**
+ * Performance test setup - run the same test multiple times to get a more accurate result
+ */
+ PERFORMANCE_TESTS: {
+ RUNS: 20,
+ },
} as const;
export default CONST;
diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.js
index 252c8380b062..4f1500132106 100644
--- a/src/components/AddPaymentMethodMenu.js
+++ b/src/components/AddPaymentMethodMenu.js
@@ -1,15 +1,19 @@
+import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import useLocalize from '@hooks/useLocalize';
import compose from '@libs/compose';
import Permissions from '@libs/Permissions';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import iouReportPropTypes from '@pages/iouReportPropTypes';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import * as Expensicons from './Icon/Expensicons';
import PopoverMenu from './PopoverMenu';
import refPropTypes from './refPropTypes';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
import withWindowDimensions from './withWindowDimensions';
const propTypes = {
@@ -19,6 +23,12 @@ const propTypes = {
/** Callback to execute when the component closes. */
onClose: PropTypes.func.isRequired,
+ /** Callback to execute when the payment method is selected. */
+ onItemSelected: PropTypes.func.isRequired,
+
+ /** The IOU/Expense report we are paying */
+ iouReport: iouReportPropTypes,
+
/** Anchor position for the AddPaymentMenu. */
anchorPosition: PropTypes.shape({
horizontal: PropTypes.number,
@@ -37,10 +47,15 @@ const propTypes = {
/** Popover anchor ref */
anchorRef: refPropTypes,
- ...withLocalizePropTypes,
+ /** Session info for the currently logged in user. */
+ session: PropTypes.shape({
+ /** Currently logged in user accountID */
+ accountID: PropTypes.number,
+ }),
};
const defaultProps = {
+ iouReport: {},
anchorPosition: {},
anchorAlignment: {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
@@ -48,31 +63,47 @@ const defaultProps = {
},
betas: [],
anchorRef: () => {},
+ session: {},
};
-function AddPaymentMethodMenu(props) {
+function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignment, anchorRef, iouReport, onItemSelected, session, betas}) {
+ const {translate} = useLocalize();
+
return (
{
- props.onItemSelected(CONST.PAYMENT_METHODS.BANK_ACCOUNT);
- },
- },
- ...(Permissions.canUseWallet(props.betas)
+ ...(ReportUtils.isIOUReport(iouReport)
? [
{
- text: props.translate('common.debitCard'),
+ text: translate('common.personalBankAccount'),
+ icon: Expensicons.Bank,
+ onSelected: () => {
+ onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT);
+ },
+ },
+ ]
+ : []),
+ ...(!ReportActionsUtils.hasRequestFromCurrentAccount(lodashGet(iouReport, 'reportID', 0), lodashGet(session, 'accountID', 0))
+ ? [
+ {
+ text: translate('common.businessBankAccount'),
+ icon: Expensicons.Building,
+ onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT),
+ },
+ ]
+ : []),
+ ...(Permissions.canUseWallet(betas)
+ ? [
+ {
+ text: translate('common.debitCard'),
icon: Expensicons.CreditCard,
- onSelected: () => props.onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD),
+ onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD),
},
]
: []),
@@ -88,10 +119,12 @@ AddPaymentMethodMenu.displayName = 'AddPaymentMethodMenu';
export default compose(
withWindowDimensions,
- withLocalize,
withOnyx({
betas: {
key: ONYXKEYS.BETAS,
},
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
}),
)(AddPaymentMethodMenu);
diff --git a/src/components/Banner.js b/src/components/Banner.tsx
similarity index 63%
rename from src/components/Banner.js
rename to src/components/Banner.tsx
index 62581f17d056..9ef807ff04e3 100644
--- a/src/components/Banner.js
+++ b/src/components/Banner.tsx
@@ -1,7 +1,6 @@
-import PropTypes from 'prop-types';
import React, {memo} from 'react';
-import {View} from 'react-native';
-import compose from '@libs/compose';
+import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
import getButtonState from '@libs/getButtonState';
import * as StyleUtils from '@styles/StyleUtils';
import useTheme from '@styles/themes/useTheme';
@@ -14,55 +13,42 @@ import PressableWithFeedback from './Pressable/PressableWithFeedback';
import RenderHTML from './RenderHTML';
import Text from './Text';
import Tooltip from './Tooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-const propTypes = {
+type BannerProps = {
/** Text to display in the banner. */
- text: PropTypes.string.isRequired,
+ text: string;
/** Should this component render the left-aligned exclamation icon? */
- shouldShowIcon: PropTypes.bool,
+ shouldShowIcon?: boolean;
/** Should this component render a close button? */
- shouldShowCloseButton: PropTypes.bool,
+ shouldShowCloseButton?: boolean;
/** Should this component render the text as HTML? */
- shouldRenderHTML: PropTypes.bool,
+ shouldRenderHTML?: boolean;
/** Callback called when the close button is pressed */
- onClose: PropTypes.func,
+ onClose?: () => void;
/** Callback called when the message is pressed */
- onPress: PropTypes.func,
+ onPress?: () => void;
/** Styles to be assigned to the Banner container */
- // eslint-disable-next-line react/forbid-prop-types
- containerStyles: PropTypes.arrayOf(PropTypes.object),
+ containerStyles?: StyleProp;
/** Styles to be assigned to the Banner text */
- // eslint-disable-next-line react/forbid-prop-types
- textStyles: PropTypes.arrayOf(PropTypes.object),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- shouldRenderHTML: false,
- shouldShowIcon: false,
- shouldShowCloseButton: false,
- onClose: undefined,
- onPress: undefined,
- containerStyles: [],
- textStyles: [],
+ textStyles?: StyleProp;
};
-function Banner(props) {
+function Banner({text, onClose, onPress, containerStyles, textStyles, shouldRenderHTML = false, shouldShowIcon = false, shouldShowCloseButton = false}: BannerProps) {
const theme = useTheme();
const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
return (
{(isHovered) => {
- const isClickable = props.onClose || props.onPress;
+ const isClickable = onClose ?? onPress;
const shouldHighlight = isClickable && isHovered;
return (
- {props.shouldShowIcon && (
+ {shouldShowIcon && (
)}
- {props.shouldRenderHTML ? (
-
+ {shouldRenderHTML ? (
+
) : (
- {props.text}
+ {text}
)}
- {props.shouldShowCloseButton && (
-
+ {shouldShowCloseButton && !!onClose && (
+
{
- let categoryInitialFocusedIndex = 0;
-
- if (!_.isEmpty(searchValue) || isCategoriesCountBelowThreshold) {
- const index = _.findIndex(lodashGet(sections, '[0].data', []), (category) => category.searchText === selectedCategory);
-
- categoryInitialFocusedIndex = index === -1 ? 0 : index;
- }
-
- return categoryInitialFocusedIndex;
- }, [selectedCategory, searchValue, isCategoriesCountBelowThreshold, sections]);
-
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue);
const shouldShowTextInput = !isCategoriesCountBelowThreshold;
+ const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (category) => category.searchText === selectedCategory)[0], 'keyForList');
return (
{
- if (event.code !== 'Space') {
- return;
- }
-
- props.onPress();
- };
-
- const firePressHandlerOnClick = (event) => {
- // Pressable can be triggered with Enter key and by a click. As this is a checkbox,
- // We do not want to toggle it, when Enter key is pressed.
- if (event.type && event.type !== 'click') {
- return;
- }
-
- props.onPress();
- };
-
- return (
-
- {props.children ? (
- props.children
- ) : (
-
- {props.isChecked && (
-
- )}
-
- )}
-
- );
-}
-
-Checkbox.propTypes = propTypes;
-Checkbox.defaultProps = defaultProps;
-Checkbox.displayName = 'Checkbox';
-
-export default Checkbox;
diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx
new file mode 100644
index 000000000000..6ee5ed1c558f
--- /dev/null
+++ b/src/components/Checkbox.tsx
@@ -0,0 +1,126 @@
+import React, {ForwardedRef, forwardRef, KeyboardEvent as ReactKeyboardEvent} from 'react';
+import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native';
+import * as StyleUtils from '@styles/StyleUtils';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
+import CONST from '@src/CONST';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import PressableWithFeedback from './Pressable/PressableWithFeedback';
+
+type CheckboxProps = ChildrenProps & {
+ /** Whether checkbox is checked */
+ isChecked?: boolean;
+
+ /** A function that is called when the box/label is pressed */
+ onPress: () => void;
+
+ /** Should the input be styled for errors */
+ hasError?: boolean;
+
+ /** Should the input be disabled */
+ disabled?: boolean;
+
+ /** Additional styles to add to checkbox button */
+ style?: StyleProp;
+
+ /** Additional styles to add to checkbox container */
+ containerStyle?: StyleProp;
+
+ /** Callback that is called when mousedown is triggered. */
+ onMouseDown?: () => void;
+
+ /** The size of the checkbox container */
+ containerSize?: number;
+
+ /** The border radius of the checkbox container */
+ containerBorderRadius?: number;
+
+ /** The size of the caret (checkmark) */
+ caretSize?: number;
+
+ /** An accessibility label for the checkbox */
+ accessibilityLabel: string;
+};
+
+function Checkbox(
+ {
+ isChecked = false,
+ hasError = false,
+ disabled = false,
+ style,
+ containerStyle,
+ children = null,
+ onMouseDown,
+ containerSize = 20,
+ containerBorderRadius = 4,
+ caretSize = 14,
+ onPress,
+ accessibilityLabel,
+ }: CheckboxProps,
+ ref: ForwardedRef,
+) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+
+ const handleSpaceKey = (event?: ReactKeyboardEvent) => {
+ if (event?.code !== 'Space') {
+ return;
+ }
+
+ onPress();
+ };
+
+ const firePressHandlerOnClick = (event?: GestureResponderEvent | KeyboardEvent) => {
+ // Pressable can be triggered with Enter key and by a click. As this is a checkbox,
+ // We do not want to toggle it, when Enter key is pressed.
+ if (event?.type && event.type !== 'click') {
+ return;
+ }
+
+ onPress();
+ };
+
+ return (
+
+ {children ?? (
+
+ {isChecked && (
+
+ )}
+
+ )}
+
+ );
+}
+
+Checkbox.displayName = 'Checkbox';
+
+export default forwardRef(Checkbox);
diff --git a/src/components/ConfirmModal.js b/src/components/ConfirmModal.js
index 541cc4c7b535..82c4b27be7f1 100755
--- a/src/components/ConfirmModal.js
+++ b/src/components/ConfirmModal.js
@@ -99,7 +99,6 @@ function ConfirmModal(props) {
shouldSetModalVisibility={props.shouldSetModalVisibility}
onModalHide={props.onModalHide}
type={props.isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
- shouldEnableFocusTrap
>
{
+ if (_.isString(message)) {
+ return false;
+ }
+ return _.get(message, 'error', '') === CONST.IOU.RECEIPT_ERROR;
+};
+
function DotIndicatorMessage(props) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -71,14 +89,33 @@ function DotIndicatorMessage(props) {
/>
- {_.map(sortedMessages, (message, i) => (
-
- {message}
-
- ))}
+ {_.map(sortedMessages, (message, i) =>
+ isReceiptError(message) ? (
+ {
+ fileDownload(message.source, message.filename);
+ }}
+ >
+
+ {Localize.translateLocal('iou.error.receiptFailureMessage')}
+ {Localize.translateLocal('iou.error.saveFileMessage')}
+ {Localize.translateLocal('iou.error.loseFileMessage')}
+
+
+ ) : (
+
+ {message}
+
+ ),
+ )}
);
diff --git a/src/components/FocusTrapView/index.native.tsx b/src/components/FocusTrapView/index.native.tsx
deleted file mode 100644
index 1190cfda4156..000000000000
--- a/src/components/FocusTrapView/index.native.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * The FocusTrap is only used on web and desktop
- */
-import FocusTrapViewProps from './types';
-
-function FocusTrapView({children}: FocusTrapViewProps) {
- return children;
-}
-
-FocusTrapView.displayName = 'FocusTrapView';
-
-export default FocusTrapView;
diff --git a/src/components/FocusTrapView/index.tsx b/src/components/FocusTrapView/index.tsx
deleted file mode 100644
index 6b52512c2e63..000000000000
--- a/src/components/FocusTrapView/index.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * The FocusTrap is only used on web and desktop
- */
-import FocusTrap from 'focus-trap-react';
-import React, {useRef} from 'react';
-import {View} from 'react-native';
-import viewRef from '@src/types/utils/viewRef';
-import FocusTrapViewProps from './types';
-
-function FocusTrapView({isEnabled = true, isActive = true, shouldEnableAutoFocus = false, ...props}: FocusTrapViewProps) {
- /**
- * Focus trap always needs a focusable element.
- * In case that we don't have any focusable elements in the modal,
- * the FocusTrap will use fallback View element using this ref.
- */
- const ref = useRef(null);
-
- return isEnabled ? (
- (shouldEnableAutoFocus && ref.current) ?? false,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- fallbackFocus: () => ref.current!,
- clickOutsideDeactivates: true,
- }}
- >
-
-
- ) : (
- props.children
- );
-}
-
-FocusTrapView.displayName = 'FocusTrapView';
-
-export default FocusTrapView;
diff --git a/src/components/FocusTrapView/types.ts b/src/components/FocusTrapView/types.ts
deleted file mode 100644
index 500b4b4315d9..000000000000
--- a/src/components/FocusTrapView/types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import {ViewProps} from 'react-native';
-import ChildrenProps from '@src/types/utils/ChildrenProps';
-
-type FocusTrapViewProps = ChildrenProps & {
- /**
- * Whether to enable the FocusTrap.
- * If the FocusTrap is disabled, we just pass the children through.
- */
- isEnabled?: boolean;
-
- /**
- * Whether to disable auto focus
- * It is used when the component inside the FocusTrap have their own auto focus logic
- */
- shouldEnableAutoFocus?: boolean;
-
- /** Whether the FocusTrap is active (listening for events) */
- isActive?: boolean;
-} & ViewProps;
-
-export default FocusTrapViewProps;
diff --git a/src/components/Form.js b/src/components/Form.js
index d5865dab44b8..ad5fcf611e9b 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -350,10 +350,18 @@ function Form(props) {
onBlur: (event) => {
// Only run validation when user proactively blurs the input.
if (Visibility.isVisible() && Visibility.hasFocus()) {
+ const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
// We delay the validation in order to prevent Checkbox loss of focus when
// the user are focusing a TextInput and proceeds to toggle a CheckBox in
// web and mobile web platforms.
+
setTimeout(() => {
+ if (
+ relatedTargetId &&
+ _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)
+ ) {
+ return;
+ }
setTouchedInput(inputID);
if (props.shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js
index 92c76da5936d..776aaae688ed 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -47,6 +47,9 @@ const propTypes = {
errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
}),
+ /** Contains draft values for each input in the form */
+ draftValues: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number, PropTypes.objectOf(Date)])),
+
/** Should the button be enabled when offline */
enabledWhenOffline: PropTypes.bool,
@@ -77,6 +80,7 @@ const defaultProps = {
formState: {
isLoading: false,
},
+ draftValues: {},
enabledWhenOffline: false,
isSubmitActionDangerous: false,
scrollContextEnabled: false,
@@ -100,7 +104,7 @@ function getInitialValueByType(valueType) {
}
}
-function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) {
+function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, draftValues, onSubmit, ...rest}) {
const inputRefs = useRef({});
const touchedInputs = useRef({});
const [inputValues, setInputValues] = useState({});
@@ -208,7 +212,9 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
if (!_.isUndefined(propsToParse.value)) {
inputValues[inputID] = propsToParse.value;
- } else if (propsToParse.shouldUseDefaultValue) {
+ } else if (propsToParse.shouldSaveDraft && !_.isUndefined(draftValues[inputID]) && _.isUndefined(inputValues[inputID])) {
+ inputValues[inputID] = draftValues[inputID];
+ } else if (propsToParse.shouldUseDefaultValue && _.isUndefined(inputValues[inputID])) {
// We force the form to set the input value from the defaultValue props if there is a saved valid value
inputValues[inputID] = propsToParse.defaultValue;
} else if (_.isUndefined(inputValues[inputID])) {
@@ -263,10 +269,15 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
onBlur: (event) => {
// Only run validation when user proactively blurs the input.
if (Visibility.isVisible() && Visibility.hasFocus()) {
+ const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
// We delay the validation in order to prevent Checkbox loss of focus when
// the user is focusing a TextInput and proceeds to toggle a CheckBox in
// web and mobile web platforms.
+
setTimeout(() => {
+ if (relatedTargetId && _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)) {
+ return;
+ }
setTouchedInput(inputID);
if (shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
@@ -293,7 +304,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
});
if (propsToParse.shouldSaveDraft) {
- FormActions.setDraftValues(propsToParse.formID, {[inputKey]: value});
+ FormActions.setDraftValues(formID, {[inputKey]: value});
}
if (_.isFunction(propsToParse.onValueChange)) {
@@ -302,7 +313,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
},
};
},
- [errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
+ [draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
);
const value = useMemo(() => ({registerInput}), [registerInput]);
@@ -333,5 +344,8 @@ export default compose(
formState: {
key: (props) => props.formID,
},
+ draftValues: {
+ key: (props) => `${props.formID}Draft`,
+ },
}),
)(FormProvider);
diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js
index 7bc558bd207d..ceb7988ca765 100755
--- a/src/components/HeaderWithBackButton/index.js
+++ b/src/components/HeaderWithBackButton/index.js
@@ -81,6 +81,7 @@ function HeaderWithBackButton({
style={[styles.touchableButtonImage]}
role="button"
accessibilityLabel={translate('common.back')}
+ nativeID={CONST.BACK_BUTTON_NATIVE_ID}
>
this.setState({shouldShowAddPaymentMenu: false})}
anchorRef={this.anchorRef}
anchorPosition={{
diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js
index 5e77947187e9..0d300c5e2179 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.js
+++ b/src/components/LHNOptionsList/LHNOptionsList.js
@@ -1,7 +1,8 @@
+import {FlashList} from '@shopify/flash-list';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
-import {FlatList, View} from 'react-native';
+import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import participantPropTypes from '@components/participantPropTypes';
@@ -11,6 +12,7 @@ import compose from '@libs/compose';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
import reportPropTypes from '@pages/reportPropTypes';
+import stylePropTypes from '@styles/stylePropTypes';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -19,12 +21,10 @@ import OptionRowLHNData from './OptionRowLHNData';
const propTypes = {
/** Wrapper style for the section list */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.arrayOf(PropTypes.object),
+ style: stylePropTypes,
/** Extra styles for the section list container */
- // eslint-disable-next-line react/forbid-prop-types
- contentContainerStyles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ contentContainerStyles: stylePropTypes.isRequired,
/** Sections for the section list */
data: PropTypes.arrayOf(PropTypes.string).isRequired,
@@ -80,7 +80,7 @@ const defaultProps = {
...withCurrentReportIDDefaultProps,
};
-const keyExtractor = (item) => item;
+const keyExtractor = (item) => `report_${item}`;
function LHNOptionsList({
style,
@@ -99,28 +99,6 @@ function LHNOptionsList({
currentReportID,
}) {
const styles = useThemeStyles();
- /**
- * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization
- * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large
- * lists.
- *
- * @param {Array} itemData - This is the same as the data we pass into the component
- * @param {Number} index the current item's index in the set of data
- *
- * @returns {Object}
- */
- const getItemLayout = useCallback(
- (itemData, index) => {
- const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight;
- return {
- length: optionHeight,
- offset: index * optionHeight,
- index,
- };
- },
- [optionMode],
- );
-
/**
* Function which renders a row in the list
*
@@ -164,20 +142,17 @@ function LHNOptionsList({
return (
-
);
diff --git a/src/components/MessagesRow.js b/src/components/MessagesRow.js
index 8fcc6525371b..c6083e85ddd1 100644
--- a/src/components/MessagesRow.js
+++ b/src/components/MessagesRow.js
@@ -16,7 +16,9 @@ import Tooltip from './Tooltip';
const propTypes = {
/** The messages to display */
- messages: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))])),
+ messages: PropTypes.objectOf(
+ PropTypes.oneOfType([PropTypes.oneOfType([PropTypes.string, PropTypes.object]), PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]),
+ ),
/** The type of message, 'error' shows a red dot, 'success' shows a green dot */
type: PropTypes.oneOf(['error', 'success']).isRequired,
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
index 710ecd79b375..f760d3c0244e 100644
--- a/src/components/Modal/index.tsx
+++ b/src/components/Modal/index.tsx
@@ -1,16 +1,13 @@
import React, {useState} from 'react';
-import FocusTrapView from '@components/FocusTrapView';
import withWindowDimensions from '@components/withWindowDimensions';
import StatusBar from '@libs/StatusBar';
import * as StyleUtils from '@styles/StyleUtils';
import useTheme from '@styles/themes/useTheme';
-import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import BaseModal from './BaseModal';
import BaseModalProps from './types';
-function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, shouldEnableFocusTrap = false, ...rest}: BaseModalProps) {
- const styles = useThemeStyles();
+function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, ...rest}: BaseModalProps) {
const theme = useTheme();
const [previousStatusBarColor, setPreviousStatusBarColor] = useState();
@@ -51,13 +48,7 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
fullscreen={fullscreen}
type={type}
>
-
- {children}
-
+ {children}
);
}
diff --git a/src/components/Modal/modalPropTypes.js b/src/components/Modal/modalPropTypes.js
index 7465e28b28ad..84e610b694e4 100644
--- a/src/components/Modal/modalPropTypes.js
+++ b/src/components/Modal/modalPropTypes.js
@@ -66,9 +66,6 @@ const propTypes = {
* */
hideModalContentWhileAnimating: PropTypes.bool,
- /** Should the modal use custom focus trap logic */
- shouldEnableFocusTrap: PropTypes.bool,
-
...windowDimensionsPropTypes,
};
@@ -87,7 +84,6 @@ const defaultProps = {
statusBarTranslucent: true,
avoidKeyboard: false,
hideModalContentWhileAnimating: false,
- shouldEnableFocusTrap: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts
index ddb51a68ba1b..3fa60e6ac765 100644
--- a/src/components/Modal/types.ts
+++ b/src/components/Modal/types.ts
@@ -61,9 +61,6 @@ type BaseModalProps = WindowDimensionsProps &
* See: https://github.com/react-native-modal/react-native-modal/pull/116
* */
hideModalContentWhileAnimating?: boolean;
-
- /** Whether the modal should use focus trap */
- shouldEnableFocusTrap?: boolean;
};
export default BaseModalProps;
diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js
index df41abea30a3..bf8bc7719316 100644
--- a/src/components/MoneyReportHeader.js
+++ b/src/components/MoneyReportHeader.js
@@ -119,7 +119,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
style={[styles.pv2]}
formattedAmount={formattedAmount}
/>
@@ -164,7 +163,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
formattedAmount={formattedAmount}
/>
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 6cf1b7e6cef1..efa9c5a49cec 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -509,7 +509,6 @@ function MoneyRequestConfirmationList(props) {
addDebitCardRoute={ROUTES.IOU_SEND_ADD_DEBIT_CARD}
currency={props.iouCurrencyCode}
policyID={props.policyID}
- shouldShowPaymentOptions
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
kycWallAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js
index e1c554dc1d37..8afda6c375bb 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.js
@@ -190,7 +190,6 @@ function OptionRow(props) {
props.optionIsFocused ? styles.sidebarLinkActive : null,
props.shouldHaveOptionSeparator && styles.borderTop,
!props.onSelectRow && !props.isDisabled ? styles.cursorDefault : null,
- props.isSelected && props.highlightSelected && styles.optionRowSelected,
]}
accessibilityLabel={props.option.text}
role={CONST.ACCESSIBILITY_ROLE.BUTTON}
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index 05a0026f7925..af74cfbbfa56 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -139,7 +139,7 @@ class BaseOptionsSelector extends Component {
this.setState(
{
allOptions: newOptions,
- focusedIndex: _.isNumber(this.props.initialFocusedIndex) ? this.props.initialFocusedIndex : newFocusedIndex,
+ focusedIndex: _.isNumber(this.props.focusedIndex) ? this.props.focusedIndex : newFocusedIndex,
},
() => {
// If we just toggled an option on a multi-selection page or cleared the search input, scroll to top
@@ -170,14 +170,14 @@ class BaseOptionsSelector extends Component {
* @returns {Number}
*/
getInitiallyFocusedIndex(allOptions) {
- if (_.isNumber(this.props.initialFocusedIndex)) {
- return this.props.initialFocusedIndex;
+ let defaultIndex;
+ if (this.props.shouldTextInputAppearBelowOptions) {
+ defaultIndex = allOptions.length;
+ } else if (this.props.focusedIndex >= 0) {
+ defaultIndex = this.props.focusedIndex;
+ } else {
+ defaultIndex = this.props.selectedOptions.length;
}
-
- if (this.props.selectedOptions.length > 0) {
- return this.props.selectedOptions.length;
- }
- const defaultIndex = this.props.shouldTextInputAppearBelowOptions ? allOptions.length : 0;
if (_.isUndefined(this.props.initiallyFocusedOptionKey)) {
return defaultIndex;
}
diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js
index 94aab8fac5f6..ba4f5beb55cd 100644
--- a/src/components/OptionsSelector/optionsSelectorPropTypes.js
+++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js
@@ -127,8 +127,8 @@ const propTypes = {
/** Whether to wrap large text up to 2 lines */
isRowMultilineSupported: PropTypes.bool,
- /** Initial focused index value */
- initialFocusedIndex: PropTypes.number,
+ /** Index for option to focus on */
+ focusedIndex: PropTypes.number,
/** Whether the text input should intercept swipes or not */
shouldTextInputInterceptSwipe: PropTypes.bool,
@@ -174,7 +174,7 @@ const defaultProps = {
onChangeText: () => {},
shouldUseStyleForChildren: true,
isRowMultilineSupported: false,
- initialFocusedIndex: undefined,
+ focusedIndex: undefined,
shouldTextInputInterceptSwipe: false,
shouldAllowScrollingChildren: false,
nestedScrollEnabled: true,
diff --git a/src/components/RadioButtonWithLabel.js b/src/components/RadioButtonWithLabel.tsx
similarity index 51%
rename from src/components/RadioButtonWithLabel.js
rename to src/components/RadioButtonWithLabel.tsx
index 178d5ebdd953..7d8df23bae49 100644
--- a/src/components/RadioButtonWithLabel.js
+++ b/src/components/RadioButtonWithLabel.tsx
@@ -1,85 +1,71 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
+import React, {ComponentType} from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
import useThemeStyles from '@styles/useThemeStyles';
import FormHelpMessage from './FormHelpMessage';
import * as Pressables from './Pressable';
import RadioButton from './RadioButton';
import Text from './Text';
-const propTypes = {
+type RadioButtonWithLabelProps = {
/** Whether the radioButton is checked */
- isChecked: PropTypes.bool.isRequired,
+ isChecked: boolean;
/** Called when the radioButton or label is pressed */
- onPress: PropTypes.func.isRequired,
+ onPress: () => void;
/** Container styles */
- style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ style?: StyleProp;
/** Text that appears next to check box */
- label: PropTypes.string,
+ label?: string;
/** Component to display for label */
- LabelComponent: PropTypes.func,
+ LabelComponent?: ComponentType;
/** Should the input be styled for errors */
- hasError: PropTypes.bool,
+ hasError?: boolean;
/** Error text to display */
- errorText: PropTypes.string,
-};
-
-const defaultProps = {
- style: [],
- label: undefined,
- LabelComponent: undefined,
- hasError: false,
- errorText: '',
+ errorText?: string;
};
const PressableWithFeedback = Pressables.PressableWithFeedback;
-function RadioButtonWithLabel(props) {
+function RadioButtonWithLabel({LabelComponent, style, label = '', hasError = false, errorText = '', isChecked, onPress}: RadioButtonWithLabelProps) {
const styles = useThemeStyles();
- const LabelComponent = props.LabelComponent;
const defaultStyles = [styles.flexRow, styles.alignItemsCenter];
- const wrapperStyles = _.isArray(props.style) ? [...defaultStyles, ...props.style] : [...defaultStyles, props.style];
- if (!props.label && !LabelComponent) {
+ if (!label && !LabelComponent) {
throw new Error('Must provide at least label or LabelComponent prop');
}
return (
<>
-
+
props.onPress()}
+ onPress={onPress}
style={[styles.flexRow, styles.flexWrap, styles.flexShrink1, styles.alignItemsCenter]}
wrapperStyle={[styles.ml3, styles.pr2, styles.w100]}
// disable hover style when disabled
hoverDimmingValue={0.8}
pressDimmingValue={0.5}
>
- {Boolean(props.label) && {props.label}}
- {Boolean(LabelComponent) && }
+ {Boolean(label) && {label}}
+ {!!LabelComponent && }
-
+
>
);
}
-RadioButtonWithLabel.propTypes = propTypes;
-RadioButtonWithLabel.defaultProps = defaultProps;
RadioButtonWithLabel.displayName = 'RadioButtonWithLabel';
export default RadioButtonWithLabel;
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index 9f291e1318f5..f04029182d45 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -254,7 +254,6 @@ function ReportPreview(props) {
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
style={[styles.mt3]}
kycWallAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
diff --git a/src/components/RoomNameInput/index.js b/src/components/RoomNameInput/index.js
index e6695405ace8..14529d7b594a 100644
--- a/src/components/RoomNameInput/index.js
+++ b/src/components/RoomNameInput/index.js
@@ -57,7 +57,7 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef,
onSelectionChange={(event) => setSelection(event.nativeEvent.selection)}
errorText={errorText}
autoCapitalize="none"
- onBlur={() => isFocused && onBlur()}
+ onBlur={(event) => isFocused && onBlur(event)}
shouldDelayFocus={shouldDelayFocus}
autoFocus={isFocused && autoFocus}
maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH}
diff --git a/src/components/RoomNameInput/index.native.js b/src/components/RoomNameInput/index.native.js
index 828affe33d07..a2c09996ad34 100644
--- a/src/components/RoomNameInput/index.native.js
+++ b/src/components/RoomNameInput/index.native.js
@@ -41,7 +41,7 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef,
errorText={errorText}
maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH}
keyboardType={keyboardType} // this is a bit hacky solution to a RN issue https://github.com/facebook/react-native/issues/27449
- onBlur={() => isFocused && onBlur()}
+ onBlur={(event) => isFocused && onBlur(event)}
autoFocus={isFocused && autoFocus}
autoCapitalize="none"
shouldDelayFocus={shouldDelayFocus}
diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js
index 01c28b3b8463..f9173c15da7d 100644
--- a/src/components/ScreenWrapper/index.js
+++ b/src/components/ScreenWrapper/index.js
@@ -1,11 +1,10 @@
-import {useIsFocused, useNavigation} from '@react-navigation/native';
+import {useNavigation} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import React, {useEffect, useRef, useState} from 'react';
import {Keyboard, PanResponder, View} from 'react-native';
import {PickerAvoidingView} from 'react-native-picker-select';
import _ from 'underscore';
import CustomDevMenu from '@components/CustomDevMenu';
-import FocusTrapView from '@components/FocusTrapView';
import HeaderGap from '@components/HeaderGap';
import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
import OfflineIndicator from '@components/OfflineIndicator';
@@ -40,8 +39,6 @@ const ScreenWrapper = React.forwardRef(
shouldDismissKeyboardBeforeClose,
onEntryTransitionEnd,
testID,
- shouldDisableFocusTrap,
- shouldEnableAutoFocus,
},
ref,
) => {
@@ -51,7 +48,6 @@ const ScreenWrapper = React.forwardRef(
const {isDevelopment} = useEnvironment();
const {isOffline} = useNetwork();
const navigation = useNavigation();
- const isFocused = useIsFocused();
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined;
const minHeight = shouldEnableMinHeight ? initialHeight : undefined;
@@ -150,27 +146,20 @@ const ScreenWrapper = React.forwardRef(
style={styles.flex1}
enabled={shouldEnablePickerAvoiding}
>
-
-
- {isDevelopment && }
- {isDevelopment && }
- {
- // If props.children is a function, call it to provide the insets to the children.
- _.isFunction(children)
- ? children({
- insets,
- safeAreaPaddingBottomStyle,
- didScreenTransitionEnd,
- })
- : children
- }
- {isSmallScreenWidth && shouldShowOfflineIndicator && }
-
+
+ {isDevelopment && }
+ {isDevelopment && }
+ {
+ // If props.children is a function, call it to provide the insets to the children.
+ _.isFunction(children)
+ ? children({
+ insets,
+ safeAreaPaddingBottomStyle,
+ didScreenTransitionEnd,
+ })
+ : children
+ }
+ {isSmallScreenWidth && shouldShowOfflineIndicator && }
diff --git a/src/components/ScreenWrapper/propTypes.js b/src/components/ScreenWrapper/propTypes.js
index 8984c860a15f..c98968bb112b 100644
--- a/src/components/ScreenWrapper/propTypes.js
+++ b/src/components/ScreenWrapper/propTypes.js
@@ -48,12 +48,6 @@ const propTypes = {
/** Styles for the offline indicator */
offlineIndicatorStyle: stylePropTypes,
-
- /** Whether to disable the focus trap */
- shouldDisableFocusTrap: PropTypes.bool,
-
- /** Whether to disable auto focus of the focus trap */
- shouldEnableAutoFocus: PropTypes.bool,
};
const defaultProps = {
@@ -69,8 +63,6 @@ const defaultProps = {
shouldShowOfflineIndicator: true,
offlineIndicatorStyle: [],
headerGapStyles: [],
- shouldDisableFocusTrap: false,
- shouldEnableAutoFocus: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js
index d2030eac8d7d..27ba3d08a16f 100644
--- a/src/components/SettlementButton.js
+++ b/src/components/SettlementButton.js
@@ -40,9 +40,6 @@ const propTypes = {
/** The route to redirect if user does not have a payment method setup */
enablePaymentsRoute: PropTypes.string.isRequired,
- /** Should we show the payment options? */
- shouldShowPaymentOptions: PropTypes.bool,
-
/** The last payment method used per policy */
nvp_lastPaymentMethod: PropTypes.objectOf(PropTypes.string),
@@ -97,7 +94,6 @@ const defaultProps = {
betas: CONST.EMPTY_ARRAY,
iouReport: CONST.EMPTY_OBJECT,
nvp_lastPaymentMethod: CONST.EMPTY_OBJECT,
- shouldShowPaymentOptions: false,
style: [],
policyID: '',
formattedAmount: '',
@@ -130,7 +126,6 @@ function SettlementButton({
onPress,
pressOnEnter,
policyID,
- shouldShowPaymentOptions,
style,
}) {
const {translate} = useLocalize();
@@ -164,34 +159,11 @@ function SettlementButton({
// To achieve the one tap pay experience we need to choose the correct payment type as default,
// if user already paid for some request or expense, let's use the last payment method or use default.
- let paymentMethod = nvp_lastPaymentMethod[policyID] || '';
- if (!shouldShowPaymentOptions) {
- if (!paymentMethod) {
- // In case the user hasn't paid a request yet, let's default to VBBA payment type in case of expense reports
- if (isExpenseReport) {
- paymentMethod = CONST.IOU.PAYMENT_TYPE.VBBA;
- } else if (canUseWallet) {
- // If they have Wallet set up, use that payment method as default
- paymentMethod = CONST.IOU.PAYMENT_TYPE.EXPENSIFY;
- } else {
- paymentMethod = CONST.IOU.PAYMENT_TYPE.ELSEWHERE;
- }
- }
-
- // In case of the settlement button in the report preview component, we do not show payment options and the label for Wallet and ACH type is simply "Pay".
- return [
- {
- ...paymentMethods[paymentMethod],
- text: paymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE ? translate('iou.payElsewhere') : translate('iou.pay'),
- },
- ];
- }
+ const paymentMethod = nvp_lastPaymentMethod[policyID] || '';
if (canUseWallet) {
buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]);
}
- if (isExpenseReport) {
- buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]);
- }
+ buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]);
buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]);
// Put the preferred payment method to the front of the array so its shown as default
@@ -199,7 +171,7 @@ function SettlementButton({
return _.sortBy(buttonOptions, (method) => (method.value === paymentMethod ? 0 : 1));
}
return buttonOptions;
- }, [betas, currency, formattedAmount, iouReport, nvp_lastPaymentMethod, policyID, shouldShowPaymentOptions, translate]);
+ }, [betas, currency, formattedAmount, iouReport, nvp_lastPaymentMethod, policyID, translate]);
const selectPaymentType = (event, iouPaymentType, triggerKYCFlow) => {
if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) {
diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js
index d6d49e3fe288..f9071aa5267d 100644
--- a/src/components/TagPicker/index.js
+++ b/src/components/TagPicker/index.js
@@ -37,17 +37,6 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
];
}, [selectedTag]);
- const initialFocusedIndex = useMemo(() => {
- if (isTagsCountBelowThreshold && selectedOptions.length > 0) {
- return _.chain(policyTagList)
- .values()
- .findIndex((policyTag) => policyTag.name === selectedOptions[0].name, true)
- .value();
- }
-
- return 0;
- }, [policyTagList, selectedOptions, isTagsCountBelowThreshold]);
-
const enabledTags = useMemo(() => {
if (!shouldShowDisabledAndSelectedOption) {
return policyTagList;
@@ -64,6 +53,8 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue);
+ const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (policyTag) => policyTag.searchText === selectedTag)[0], 'keyForList');
+
return (
diff --git a/src/components/UnreadActionIndicator.js b/src/components/UnreadActionIndicator.tsx
similarity index 53%
rename from src/components/UnreadActionIndicator.js
rename to src/components/UnreadActionIndicator.tsx
index 7555c93c2326..b34f962e57bd 100755
--- a/src/components/UnreadActionIndicator.js
+++ b/src/components/UnreadActionIndicator.tsx
@@ -1,26 +1,31 @@
import React from 'react';
import {View} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import Text from './Text';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-function UnreadActionIndicator(props) {
+type UnreadActionIndicatorProps = {
+ reportActionID: string;
+};
+
+function UnreadActionIndicator({reportActionID}: UnreadActionIndicatorProps) {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
return (
- {props.translate('common.new')}
+ {translate('common.new')}
);
}
-UnreadActionIndicator.propTypes = {...withLocalizePropTypes};
-
UnreadActionIndicator.displayName = 'UnreadActionIndicator';
-export default withLocalize(UnreadActionIndicator);
+
+export default UnreadActionIndicator;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 4c6ea25eb2c8..96e2e99824cd 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -215,6 +215,8 @@ export default {
more: 'More',
debitCard: 'Debit card',
bankAccount: 'Bank account',
+ personalBankAccount: 'Personal bank account',
+ businessBankAccount: 'Business bank account',
join: 'Join',
leave: 'Leave',
decline: 'Decline',
@@ -591,6 +593,9 @@ export default {
invalidSplit: 'Split amounts do not equal total amount',
other: 'Unexpected error, please try again later',
genericCreateFailureMessage: 'Unexpected error requesting money, please try again later',
+ receiptFailureMessage: "The receipt didn't upload. ",
+ saveFileMessage: 'Download the file ',
+ loseFileMessage: 'or dismiss this error and lose it',
genericDeleteFailureMessage: 'Unexpected error deleting the money request, please try again later',
genericEditFailureMessage: 'Unexpected error editing the money request, please try again later',
genericSmartscanFailureMessage: 'Transaction is missing fields',
@@ -903,6 +908,8 @@ export default {
activatePhysicalCard: 'Activate physical card',
error: {
thatDidntMatch: "That didn't match the last 4 digits on your card. Please try again.",
+ throttled:
+ "You've incorrectly entered the last 4 digits of your Expensify Card too many times. If you're sure the numbers are correct, please reach out to Concierge to resolve. Otherwise, try again later.",
},
},
getPhysicalCard: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 85eab5c3f14d..3f8f68977549 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -205,6 +205,8 @@ export default {
more: 'Más',
debitCard: 'Tarjeta de débito',
bankAccount: 'Cuenta bancaria',
+ personalBankAccount: 'Cuenta bancaria personal',
+ businessBankAccount: 'Cuenta bancaria comercial',
join: 'Unirse',
leave: 'Salir',
decline: 'Rechazar',
@@ -585,6 +587,9 @@ export default {
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',
+ receiptFailureMessage: 'El recibo no se subió. ',
+ saveFileMessage: 'Guarda el archivo ',
+ loseFileMessage: 'o descarta este error y piérdelo',
genericDeleteFailureMessage: 'Error inesperado eliminando la solicitud de dinero. Por favor, inténtalo más tarde',
genericEditFailureMessage: 'Error inesperado al guardar la solicitud de dinero. Por favor, inténtalo más tarde',
genericSmartscanFailureMessage: 'La transacción tiene campos vacÃos',
@@ -899,6 +904,8 @@ export default {
activatePhysicalCard: 'Activar tarjeta fÃsica',
error: {
thatDidntMatch: 'Los 4 últimos dÃgitos de tu tarjeta no coinciden. Por favor, inténtalo de nuevo.',
+ throttled:
+ 'Has introducido incorrectamente los 4 últimos dÃgitos de tu tarjeta Expensify demasiadas veces. Si estás seguro de que los números son correctos, ponte en contacto con ConserjerÃa para solucionarlo. De lo contrario, inténtalo de nuevo más tarde.',
},
},
// TODO: add translation
diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts
index 5bc8ea1d3508..46bdd510f5c4 100644
--- a/src/libs/ErrorUtils.ts
+++ b/src/libs/ErrorUtils.ts
@@ -42,6 +42,14 @@ function getMicroSecondOnyxError(error: string): Record {
return {[DateUtils.getMicroseconds()]: error};
}
+/**
+ * Method used to get an error object with microsecond as the key and an object as the value.
+ * @param error - error key or message to be saved
+ */
+function getMicroSecondOnyxErrorObject(error: Record): Record> {
+ return {[DateUtils.getMicroseconds()]: error};
+}
+
type OnyxDataWithErrors = {
errors?: Errors;
};
@@ -111,4 +119,4 @@ function addErrorMessage(errors: ErrorsList, inpu
}
}
-export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage};
+export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getMicroSecondOnyxErrorObject, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage};
diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js
index 7a4cbf7db3c5..44d996282617 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js
+++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js
@@ -29,6 +29,7 @@ function Overlay(props) {
onPress={props.onPress}
accessibilityLabel={translate('common.close')}
role={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ nativeID={CONST.OVERLAY.TOP_BUTTON_NATIVE_ID}
/>
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index 2629d36999bf..bfc0f509373e 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -98,6 +98,40 @@ function getDistanceFromPathInRootNavigator(path) {
return -1;
}
+/**
+ * Returns the current active route
+ * @returns {String}
+ */
+function getActiveRoute() {
+ const currentRoute = navigationRef.current && navigationRef.current.getCurrentRoute();
+ const currentRouteHasName = lodashGet(currentRoute, 'name', false);
+ if (!currentRouteHasName) {
+ return '';
+ }
+
+ const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config);
+
+ if (routeFromState) {
+ return routeFromState;
+ }
+
+ return '';
+}
+
+/**
+ * Check whether the passed route is currently Active or not.
+ *
+ * Building path with getPathFromState since navigationRef.current.getCurrentRoute().path
+ * is undefined in the first navigation.
+ *
+ * @param {String} routePath Path to check
+ * @return {Boolean} is active
+ */
+function isActiveRoute(routePath) {
+ // We remove First forward slash from the URL before matching
+ return getActiveRoute().substring(1) === routePath;
+}
+
/**
* Main navigation method for redirecting to a route.
* @param {String} route
@@ -111,7 +145,7 @@ function navigate(route = ROUTES.HOME, type) {
pendingRoute = route;
return;
}
- linkTo(navigationRef.current, route, type);
+ linkTo(navigationRef.current, route, type, isActiveRoute(route));
}
/**
@@ -221,26 +255,6 @@ function dismissModal(targetReportID) {
}
}
-/**
- * Returns the current active route
- * @returns {String}
- */
-function getActiveRoute() {
- const currentRoute = navigationRef.current && navigationRef.current.getCurrentRoute();
- const currentRouteHasName = lodashGet(currentRoute, 'name', false);
- if (!currentRouteHasName) {
- return '';
- }
-
- const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config);
-
- if (routeFromState) {
- return routeFromState;
- }
-
- return '';
-}
-
/**
* Returns the current active route without the URL params
* @returns {String}
@@ -265,20 +279,6 @@ function getRouteNameFromStateEvent(event) {
}
}
-/**
- * Check whether the passed route is currently Active or not.
- *
- * Building path with getPathFromState since navigationRef.current.getCurrentRoute().path
- * is undefined in the first navigation.
- *
- * @param {String} routePath Path to check
- * @return {Boolean} is active
- */
-function isActiveRoute(routePath) {
- // We remove First forward slash from the URL before matching
- return getActiveRoute().substring(1) === routePath;
-}
-
/**
* Navigate to the route that we originally intended to go to
* but the NavigationContainer was not ready when navigate() was called
diff --git a/src/libs/Navigation/linkTo.js b/src/libs/Navigation/linkTo.js
index 55bd4b31dbdf..ca87a0d7b79c 100644
--- a/src/libs/Navigation/linkTo.js
+++ b/src/libs/Navigation/linkTo.js
@@ -41,7 +41,7 @@ function getMinimalAction(action, state) {
return currentAction;
}
-export default function linkTo(navigation, path, type) {
+export default function linkTo(navigation, path, type, isActiveRoute) {
if (navigation === undefined) {
throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?");
}
@@ -86,7 +86,7 @@ export default function linkTo(navigation, path, type) {
// There are situations where a route already exists on the current navigation stack
// But we want to push the same route instead of going back in the stack
// Which would break the user navigation history
- if (type === CONST.NAVIGATION.ACTION_TYPE.PUSH) {
+ if (!isActiveRoute && type === CONST.NAVIGATION.ACTION_TYPE.PUSH) {
minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
}
// There are situations when the user is trying to access a route which he has no access to
diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts
index 5da032baaf45..d4aee4a221e5 100644
--- a/src/libs/Network/SequentialQueue.ts
+++ b/src/libs/Network/SequentialQueue.ts
@@ -160,7 +160,7 @@ NetworkStore.onReconnection(flush);
function push(request: OnyxRequest) {
// Add request to Persisted Requests so that it can be retried if it fails
- PersistedRequests.save(request);
+ PersistedRequests.save([request]);
// If we are offline we don't need to trigger the queue to empty as it will happen when we come back online
if (NetworkStore.isOffline()) {
diff --git a/src/libs/Network/enhanceParameters.ts b/src/libs/Network/enhanceParameters.ts
index 3fadeea7447c..6ff54f94bc88 100644
--- a/src/libs/Network/enhanceParameters.ts
+++ b/src/libs/Network/enhanceParameters.ts
@@ -37,8 +37,5 @@ export default function enhanceParameters(command: string, parameters: Record {
const connectionID = Onyx.connect({
- key: ONYXKEYS.ONYX_UPDATES.LAST_UPDATE_ID,
- callback: (lastUpdateID) => {
+ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
+ callback: (lastUpdateIDAppliedToClient) => {
Onyx.disconnect(connectionID);
- resolve(lastUpdateID);
+ resolve(lastUpdateIDAppliedToClient);
},
});
});
@@ -26,15 +28,19 @@ export default function backgroundRefresh() {
return;
}
- getLastOnyxUpdateID().then((lastUpdateID) => {
- /**
- * ReconnectApp waits on the isReadyToOpenApp promise to resolve and this normally only resolves when the LHN is rendered.
- * However on Android, this callback is run in the background using a Headless JS task which does not render the React UI,
- * so we must manually run confirmReadyToOpenApp here instead.
- *
- * See more here: https://reactnative.dev/docs/headless-js-android
- */
- App.confirmReadyToOpenApp();
- App.reconnectApp(lastUpdateID);
- });
+ getLastOnyxUpdateID()
+ .then((lastUpdateIDAppliedToClient) => {
+ /**
+ * ReconnectApp waits on the isReadyToOpenApp promise to resolve and this normally only resolves when the LHN is rendered.
+ * However on Android, this callback is run in the background using a Headless JS task which does not render the React UI,
+ * so we must manually run confirmReadyToOpenApp here instead.
+ *
+ * See more here: https://reactnative.dev/docs/headless-js-android
+ */
+ App.confirmReadyToOpenApp();
+ App.reconnectApp(lastUpdateIDAppliedToClient);
+ })
+ .catch((error) => {
+ Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PushNotification] backgroundRefresh failed. This should never happen.`, {error});
+ });
}
diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts
index b37db2584394..5bd70fee4d83 100644
--- a/src/libs/PaymentUtils.ts
+++ b/src/libs/PaymentUtils.ts
@@ -26,7 +26,7 @@ function hasExpensifyPaymentMethod(fundList: Record, bankAccountLi
function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData']): string {
if (account) {
- if (accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT && 'accountNumber' in account) {
+ if (accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT && 'accountNumber' in account) {
return `${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`;
}
if (accountType === CONST.PAYMENT_METHODS.DEBIT_CARD && 'cardNumber' in account) {
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 4af2d0c8a3c2..333d621167b7 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -69,7 +69,7 @@ function isDeletedParentAction(reportAction: OnyxEntry): boolean {
}
function isReversedTransaction(reportAction: OnyxEntry) {
- return (reportAction?.message?.[0].isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
+ return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
}
function isPendingRemove(reportAction: OnyxEntry): boolean {
@@ -631,6 +631,26 @@ function isNotifiableReportAction(reportAction: OnyxEntry): boolea
return actions.includes(reportAction.actionName);
}
+/**
+ * Helper method to determine if the provided accountID has made a request on the specified report.
+ *
+ * @param reportID
+ * @param currentAccountID
+ * @returns
+ */
+function hasRequestFromCurrentAccount(reportID: string, currentAccountID: number): boolean {
+ if (!reportID) {
+ return false;
+ }
+
+ const reportActions = Object.values(getAllReportActions(reportID));
+ if (reportActions.length === 0) {
+ return false;
+ }
+
+ return reportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && action.actorAccountID === currentAccountID);
+}
+
export {
extractLinksFromMessageHtml,
getAllReportActions,
@@ -671,6 +691,7 @@ export {
isReimbursementQueuedAction,
shouldReportActionBeVisible,
shouldReportActionBeVisibleAsLastAction,
+ hasRequestFromCurrentAccount,
getFirstVisibleReportActionID,
isChannelLogMemberAction,
};
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index f5f8bf879284..78478334f810 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -1859,9 +1859,10 @@ function getTransactionReportName(reportAction) {
* @param {Object} [reportAction={}] This can be either a report preview action or the IOU action
* @param {Boolean} [shouldConsiderReceiptBeingScanned=false]
* @param {Boolean} isPreviewMessageForParentChatReport
+ * @param {Object} [policy]
* @returns {String}
*/
-function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceiptBeingScanned = false, isPreviewMessageForParentChatReport = false) {
+function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceiptBeingScanned = false, isPreviewMessageForParentChatReport = false, policy = undefined) {
const reportActionMessage = lodashGet(reportAction, 'message[0].html', '');
if (_.isEmpty(report) || !report.reportID) {
@@ -1885,7 +1886,7 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip
}
const totalAmount = getMoneyRequestReimbursableTotal(report);
- const payerName = isExpenseReport(report) ? getPolicyName(report) : getDisplayNameForParticipant(report.managerID, true);
+ const payerName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report.managerID, true);
const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency);
if (isReportApproved(report) && getPolicyType(report, allPolicies) === CONST.POLICY.TYPE.CORPORATE) {
@@ -2721,6 +2722,7 @@ function buildOptimisticIOUReportAction(
whisperedToAccountIDs: _.contains([CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING], receipt.state) ? [currentUserAccountID] : [],
};
}
+
/**
* Builds an optimistic APPROVED report action with a randomly generated reportActionID.
*
@@ -2759,6 +2761,56 @@ function buildOptimisticApprovedReportAction(amount, currency, expenseReportID)
};
}
+/**
+ * Builds an optimistic MOVED report action with a randomly generated reportActionID.
+ * This action is used when we move reports across workspaces.
+ *
+ * @param {String} fromPolicyID
+ * @param {String} toPolicyID
+ * @param {Number} newParentReportID
+ * @param {Number} movedReportID
+ *
+ * @returns {Object}
+ */
+function buildOptimisticMovedReportAction(fromPolicyID, toPolicyID, newParentReportID, movedReportID) {
+ const originalMessage = {
+ fromPolicyID,
+ toPolicyID,
+ newParentReportID,
+ movedReportID,
+ };
+
+ const policyName = getPolicyName(allReports[`${ONYXKEYS.COLLECTION.REPORT}${newParentReportID}`]);
+ const movedActionMessage = [
+ {
+ html: `moved the report to the ${policyName} workspace`,
+ text: `moved the report to the ${policyName} workspace`,
+ type: CONST.REPORT.MESSAGE.TYPE.COMMENT,
+ },
+ ];
+
+ return {
+ actionName: CONST.REPORT.ACTIONS.TYPE.MOVED,
+ actorAccountID: currentUserAccountID,
+ automatic: false,
+ avatar: lodashGet(currentUserPersonalDetails, 'avatar', UserUtils.getDefaultAvatarURL(currentUserAccountID)),
+ isAttachment: false,
+ originalMessage,
+ message: movedActionMessage,
+ person: [
+ {
+ style: 'strong',
+ text: lodashGet(currentUserPersonalDetails, 'displayName', currentUserEmail),
+ type: 'TEXT',
+ },
+ ],
+ reportActionID: NumberUtils.rand64(),
+ shouldShow: true,
+ created: DateUtils.getDBTime(),
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ };
+}
+
/**
* Builds an optimistic SUBMITTED report action with a randomly generated reportActionID.
*
@@ -4403,6 +4455,7 @@ export {
buildOptimisticEditedTaskReportAction,
buildOptimisticIOUReport,
buildOptimisticApprovedReportAction,
+ buildOptimisticMovedReportAction,
buildOptimisticSubmittedReportAction,
buildOptimisticExpenseReport,
buildOptimisticIOUReportAction,
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 939a11dad511..8d44e3898062 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -141,6 +141,18 @@ function resetMoneyRequestInfo(id = '') {
});
}
+/**
+ * Helper function to get the receipt error for money requests, or the generic error if there's no receipt
+ *
+ * @param {Object} receipt
+ * @returns {Object}
+ */
+function getReceiptError(receipt) {
+ return _.isEmpty(receipt)
+ ? ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage')
+ : ErrorUtils.getMicroSecondOnyxErrorObject({error: CONST.IOU.RECEIPT_ERROR, source: receipt.source, filename: receipt.filename});
+}
+
function buildOnyxDataForMoneyRequest(
chatReport,
iouReport,
@@ -344,7 +356,7 @@ function buildOnyxDataForMoneyRequest(
...(isNewChatReport
? {
[chatCreatedAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(transaction.receipt),
},
[reportPreviewAction.reportActionID]: {
errors: ErrorUtils.getMicroSecondOnyxError(null),
@@ -353,7 +365,7 @@ function buildOnyxDataForMoneyRequest(
: {
[reportPreviewAction.reportActionID]: {
created: reportPreviewAction.created,
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(transaction.receipt),
},
}),
},
@@ -365,7 +377,7 @@ function buildOnyxDataForMoneyRequest(
...(isNewIOUReport
? {
[iouCreatedAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(transaction.receipt),
},
[iouAction.reportActionID]: {
errors: ErrorUtils.getMicroSecondOnyxError(null),
@@ -373,7 +385,7 @@ function buildOnyxDataForMoneyRequest(
}
: {
[iouAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(transaction.receipt),
},
}),
},
@@ -1433,7 +1445,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`,
value: {
[splitIOUReportAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(receipt),
},
},
});
@@ -1456,7 +1468,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co
errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'),
},
[splitIOUReportAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(receipt),
},
},
},
diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts
index bcc5d8142470..02f0b49fe3d2 100644
--- a/src/libs/actions/PaymentMethods.ts
+++ b/src/libs/actions/PaymentMethods.ts
@@ -81,7 +81,7 @@ function getMakeDefaultPaymentOnyxData(
key: ONYXKEYS.USER_WALLET,
value: {
walletLinkedAccountID: bankAccountID || fundID,
- walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
// Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server.
errors: null,
},
@@ -91,7 +91,7 @@ function getMakeDefaultPaymentOnyxData(
key: ONYXKEYS.USER_WALLET,
value: {
walletLinkedAccountID: bankAccountID || fundID,
- walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
},
},
];
@@ -99,7 +99,7 @@ function getMakeDefaultPaymentOnyxData(
if (previousPaymentMethod?.methodID) {
onyxData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
value: {
[previousPaymentMethod.methodID]: {
isDefault: !isOptimisticData,
@@ -111,7 +111,7 @@ function getMakeDefaultPaymentOnyxData(
if (currentPaymentMethod?.methodID) {
onyxData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
value: {
[currentPaymentMethod.methodID]: {
isDefault: isOptimisticData,
@@ -223,7 +223,8 @@ function clearDebitCardFormErrorAndSubmit() {
*
*/
function transferWalletBalance(paymentMethod: PaymentMethod) {
- const paymentMethodIDKey = paymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
+ const paymentMethodIDKey =
+ paymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
type TransferWalletBalanceParameters = Partial, number | undefined>>;
diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts
index c788d69de70e..c35de9ee94c4 100644
--- a/src/libs/actions/PersistedRequests.ts
+++ b/src/libs/actions/PersistedRequests.ts
@@ -17,17 +17,15 @@ function clear() {
return Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, []);
}
-function save(requestToPersist: Request) {
- // Check for a request w/ matching idempotencyKey in the queue
- const existingRequestIndex = persistedRequests.findIndex((request) => request.data?.idempotencyKey && request.data?.idempotencyKey === requestToPersist.data?.idempotencyKey);
- if (existingRequestIndex > -1) {
- // Merge the new request into the existing one, keeping its place in the queue
- persistedRequests.splice(existingRequestIndex, 1, requestToPersist);
+function save(requestsToPersist: Request[]) {
+ let requests: Request[] = [];
+ if (persistedRequests.length) {
+ requests = persistedRequests.concat(requestsToPersist);
} else {
- // If not, push the new request to the end of the queue
- persistedRequests.push(requestToPersist);
+ requests = requestsToPersist;
}
- Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests);
+ persistedRequests = requests;
+ Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests);
}
function remove(requestToRemove: Request) {
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index 4df510d44db7..ebc1cdf9a2e1 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -7,12 +7,15 @@ import lodashUnion from 'lodash/union';
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import * as API from '@libs/API';
+import DateUtils from '@libs/DateUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import Log from '@libs/Log';
import * as NumberUtils from '@libs/NumberUtils';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import * as TransactionUtils from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -415,9 +418,10 @@ function removeMembers(accountIDs, policyID) {
*
* @param {String} policyID
* @param {Object} invitedEmailsToAccountIDs
+ * @param {Boolean} hasOutstandingChildRequest
* @returns {Object} - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID)
*/
-function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) {
+function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, hasOutstandingChildRequest = false) {
const workspaceMembersChats = {
onyxSuccessData: [],
onyxOptimisticData: [],
@@ -463,6 +467,7 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) {
createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
},
isOptimisticReport: true,
+ hasOutstandingChildRequest,
},
});
workspaceMembersChats.onyxOptimisticData.push({
@@ -1458,6 +1463,403 @@ function buildOptimisticPolicyRecentlyUsedCategories(policyID, category) {
return lodashUnion([category], policyRecentlyUsedCategories);
}
+/**
+ * This flow is used for bottom up flow converting IOU report to an expense report. When user takes this action,
+ * we create a Collect type workspace when the person taking the action becomes an owner and an admin, while we
+ * add a new member to the workspace as an employee and convert the IOU report passed as a param into an expense report.
+ *
+ * @param {Object} iouReport
+ * @returns {String} policyID of the workspace we have created
+ */
+function createWorkspaceFromIOUPayment(iouReport) {
+ // This flow only works for IOU reports
+ if (!ReportUtils.isIOUReport(iouReport)) {
+ return;
+ }
+
+ // Generate new variables for the policy
+ const policyID = generatePolicyID();
+ const workspaceName = generateDefaultWorkspaceName(sessionEmail);
+ const employeeAccountID = iouReport.ownerAccountID;
+ const employeeEmail = iouReport.ownerEmail;
+ const {customUnits, customUnitID, customUnitRateID} = buildOptimisticCustomUnits();
+ const oldPersonalPolicyID = iouReport.policyID;
+ const iouReportID = iouReport.reportID;
+
+ const {
+ announceChatReportID,
+ announceChatData,
+ announceReportActionData,
+ announceCreatedReportActionID,
+ adminsChatReportID,
+ adminsChatData,
+ adminsReportActionData,
+ adminsCreatedReportActionID,
+ expenseChatReportID: workspaceChatReportID,
+ expenseChatData: workspaceChatData,
+ expenseReportActionData: workspaceChatReportActionData,
+ expenseCreatedReportActionID: workspaceChatCreatedReportActionID,
+ } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName);
+
+ // Create the workspace chat for the employee whose IOU is being paid
+ const employeeWorkspaceChat = createPolicyExpenseChats(policyID, {[employeeEmail]: employeeAccountID}, true);
+ const newWorkspace = {
+ id: policyID,
+
+ // We are creating a collect policy in this case
+ type: CONST.POLICY.TYPE.TEAM,
+ name: workspaceName,
+ role: CONST.POLICY.ROLE.ADMIN,
+ owner: sessionEmail,
+ isPolicyExpenseChatEnabled: true,
+
+ // Setting the currency to USD as we can only add the VBBA for this policy currency right now
+ outputCurrency: CONST.CURRENCY.USD,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ customUnits,
+ };
+
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: newWorkspace,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`,
+ value: {
+ [sessionAccountID]: {
+ role: CONST.POLICY.ROLE.ADMIN,
+ errors: {},
+ },
+ [employeeAccountID]: {
+ role: CONST.POLICY.ROLE.USER,
+ errors: {},
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ ...announceChatData,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`,
+ value: announceReportActionData,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ ...adminsChatData,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`,
+ value: adminsReportActionData,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ ...workspaceChatData,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`,
+ value: workspaceChatReportActionData,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`,
+ value: null,
+ },
+ ...employeeWorkspaceChat.onyxOptimisticData,
+ ];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {pendingAction: null},
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: null,
+ },
+ pendingAction: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`,
+ value: {
+ [_.keys(announceChatData)[0]]: {
+ pendingAction: null,
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: null,
+ },
+ pendingAction: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`,
+ value: {
+ [_.keys(adminsChatData)[0]]: {
+ pendingAction: null,
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: null,
+ },
+ pendingAction: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`,
+ value: {
+ [_.keys(workspaceChatData)[0]]: {
+ pendingAction: null,
+ },
+ },
+ },
+ ...employeeWorkspaceChat.onyxSuccessData,
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`,
+ value: null,
+ },
+ ];
+
+ // Compose the memberData object which is used to add the employee to the workspace and
+ // optimistically create the workspace chat for them.
+ const memberData = {
+ accountID: Number(employeeAccountID),
+ email: employeeEmail,
+ workspaceChatReportID: employeeWorkspaceChat.reportCreationData[employeeEmail].reportID,
+ workspaceChatCreatedReportActionID: employeeWorkspaceChat.reportCreationData[employeeEmail].reportActionID,
+ };
+
+ const oldChatReportID = iouReport.chatReportID;
+
+ // Next we need to convert the IOU report to Expense report.
+ // We need to change:
+ // - report type
+ // - change the sign of the report total
+ // - update its policyID and policyName
+ // - update the chatReportID to point to the new workspace chat
+ const expenseReport = {
+ ...iouReport,
+ chatReportID: memberData.workspaceChatReportID,
+ policyID,
+ policyName: workspaceName,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ total: -iouReport.total,
+ };
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`,
+ value: expenseReport,
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`,
+ value: iouReport,
+ });
+
+ // The expense report transactions need to have the amount reversed to negative values
+ const reportTransactions = TransactionUtils.getAllReportTransactions(iouReportID);
+
+ // For performance reasons, we are going to compose a merge collection data for transactions
+ const transactionsOptimisticData = {};
+ const transactionFailureData = {};
+ _.each(reportTransactions, (transaction) => {
+ transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = {
+ ...transaction,
+ amount: -transaction.amount,
+ modifiedAmount: transaction.modifiedAmount ? -transaction.modifiedAmount : 0,
+ };
+
+ transactionFailureData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction;
+ });
+
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE_COLLECTION,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}`,
+ value: transactionsOptimisticData,
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE_COLLECTION,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}`,
+ value: transactionFailureData,
+ });
+
+ // We need to move the report preview action from the DM to the workspace chat.
+ const reportPreview = ReportActionsUtils.getParentReportAction(iouReport);
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {[reportPreview.reportActionID]: null},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {[reportPreview.reportActionID]: reportPreview},
+ });
+
+ // To optimistically remove the GBR from the DM we need to update the hasOutstandingChildRequest param to false
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`,
+ value: {
+ hasOutstandingChildRequest: false,
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`,
+ value: {
+ hasOutstandingChildRequest: true,
+ },
+ });
+
+ // Update the created timestamp of the report preview action to be after the workspace chat created timestamp.
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`,
+ value: {
+ [reportPreview.reportActionID]: {
+ ...reportPreview,
+ message: ReportUtils.getReportPreviewMessage(expenseReport, {}, false, false, newWorkspace),
+ created: DateUtils.getDBTime(),
+ },
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`,
+ value: {[reportPreview.reportActionID]: null},
+ });
+
+ // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved
+ const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID, policyID, memberData.workspaceChatReportID, iouReportID);
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {[movedReportAction.reportActionID]: movedReportAction},
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {
+ [movedReportAction.reportActionID]: {
+ ...movedReportAction,
+ pendingAction: null,
+ },
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {[movedReportAction.reportActionID]: null},
+ });
+
+ API.write(
+ 'CreateWorkspaceFromIOUPayment',
+ {
+ policyID,
+ announceChatReportID,
+ adminsChatReportID,
+ expenseChatReportID: workspaceChatReportID,
+ ownerEmail: '',
+ makeMeAdmin: false,
+ policyName: workspaceName,
+ type: CONST.POLICY.TYPE.TEAM,
+ announceCreatedReportActionID,
+ adminsCreatedReportActionID,
+ expenseCreatedReportActionID: workspaceChatCreatedReportActionID,
+ customUnitID,
+ customUnitRateID,
+ iouReportID,
+ memberData: JSON.stringify(memberData),
+ reportActionID: movedReportAction.reportActionID,
+ },
+ {optimisticData, successData, failureData},
+ );
+
+ return policyID;
+}
+
export {
removeMembers,
addMembersToWorkspace,
@@ -1484,6 +1886,7 @@ export {
openWorkspaceMembersPage,
openWorkspaceInvitePage,
removeWorkspace,
+ createWorkspaceFromIOUPayment,
setWorkspaceInviteMembersDraft,
clearErrors,
dismissAddedWithPrimaryLoginMessages,
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index 9bc2aa1b3f2f..a03488429405 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -470,9 +470,6 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
if (!reportID) {
return;
}
-
- const commandName = 'OpenReport';
-
const optimisticReportData = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -538,7 +535,6 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
emailList: participantLoginList ? participantLoginList.join(',') : '',
accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '',
parentReportActionID,
- idempotencyKey: `${commandName}_${reportID}`,
};
if (isFromDeepLink) {
@@ -616,7 +612,6 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
// Add the createdReportActionID parameter to the API call
params.createdReportActionID = optimisticCreatedAction.reportActionID;
- params.idempotencyKey = `${params.idempotencyKey}_NewReport_${optimisticCreatedAction.reportActionID}`;
// If we are creating a thread, ensure the report action has childReportID property added
if (newReportObject.parentReportID && parentReportActionID) {
@@ -637,12 +632,12 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
if (isFromDeepLink) {
// eslint-disable-next-line rulesdir/no-api-side-effects-method
- API.makeRequestWithSideEffects(commandName, params, onyxData).finally(() => {
+ API.makeRequestWithSideEffects('OpenReport', params, onyxData).finally(() => {
Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false);
});
} else {
// eslint-disable-next-line rulesdir/no-multiple-api-calls
- API.write(commandName, params, onyxData);
+ API.write('OpenReport', params, onyxData);
}
}
diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts
index 5bac47fb63ec..618571ddf400 100644
--- a/src/libs/fileDownload/FileUtils.ts
+++ b/src/libs/fileDownload/FileUtils.ts
@@ -165,7 +165,7 @@ const readFileAsync: ReadFileAsync = (path, fileName, onSuccess, onFailure = ()
}
res.blob()
.then((blob) => {
- const file = new File([blob], cleanFileName(fileName));
+ const file = new File([blob], cleanFileName(fileName), {type: blob.type});
file.source = path;
// For some reason, the File object on iOS does not have a uri property
// so images aren't uploaded correctly to the backend
diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js
index 17ea63ca1003..4b3c927ef317 100755
--- a/src/pages/ProfilePage.js
+++ b/src/pages/ProfilePage.js
@@ -148,10 +148,7 @@ function ProfilePage(props) {
}, [accountID, hasMinimumDetails]);
return (
-
+
Navigation.goBack(navigateBackTo)}
diff --git a/src/pages/ReimbursementAccount/BankAccountManualStep.js b/src/pages/ReimbursementAccount/BankAccountManualStep.js
index b46d3aa0aa28..a953dca378fd 100644
--- a/src/pages/ReimbursementAccount/BankAccountManualStep.js
+++ b/src/pages/ReimbursementAccount/BankAccountManualStep.js
@@ -3,7 +3,8 @@ import React, {useCallback} from 'react';
import {Image} from 'react-native';
import _ from 'underscore';
import CheckboxWithLabel from '@components/CheckboxWithLabel';
-import Form from '@components/Form';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
@@ -86,7 +87,7 @@ function BankAccountManualStep(props) {
guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_BANK_ACCOUNT}
onBackButtonPress={props.onBackButtonPress}
/>
-
-
-
-
-
+
);
}
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index abfe625f1508..d39af722a1df 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -14,7 +14,6 @@ import participantPropTypes from '@components/participantPropTypes';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import RoomHeaderAvatars from '@components/RoomHeaderAvatars';
import ScreenWrapper from '@components/ScreenWrapper';
-import Text from '@components/Text';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
@@ -154,19 +153,17 @@ function ReportDetailsPage(props) {
const icons = useMemo(() => ReportUtils.getIcons(props.report, props.personalDetails, props.policies), [props.report, props.personalDetails, props.policies]);
const chatRoomSubtitleText = chatRoomSubtitle ? (
-
- {chatRoomSubtitle}
-
+ textStyles={[styles.sidebarLinkText, styles.textLabelSupporting, styles.pre, styles.mt1]}
+ shouldUseFullTitle
+ />
) : null;
return (
-
+
{isPolicyAdmin ? (
-
-
- {isLoading && }
+
+
+ {isLoading && optionListItems.length === 0 && (
+
+
+
+ )}
+
);
}
diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
index efb5e839f618..5b7a126a4655 100644
--- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
+++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
@@ -30,7 +30,6 @@ function BaseSidebarScreen(props) {
shouldEnableKeyboardAvoidingView={false}
style={[styles.sidebar, Browser.isMobile() ? styles.userSelectNone : {}]}
testID={BaseSidebarScreen.displayName}
- shouldDisableFocusTrap
>
{({insets}) => (
<>
diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js
index 2ebe96d60ed8..d1fe21d8cf4e 100644
--- a/src/pages/iou/SplitBillDetailsPage.js
+++ b/src/pages/iou/SplitBillDetailsPage.js
@@ -109,10 +109,7 @@ function SplitBillDetailsPage(props) {
);
return (
-
+
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
index 9a9e42f75576..31a33efa3996 100644
--- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo} from 'react';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import useAnimatedStepContext from '@components/AnimatedStep/useAnimatedStepContext';
import * as TwoFactorAuthActions from '@userActions/TwoFactorAuthActions';
@@ -13,20 +13,30 @@ import TwoFactorAuthContext from './TwoFactorAuthContext';
import {defaultAccount, TwoFactorAuthPropTypes} from './TwoFactorAuthPropTypes';
function TwoFactorAuthSteps({account = defaultAccount}) {
- const currentStep = useMemo(() => {
- if (account.twoFactorAuthStep) {
- return account.twoFactorAuthStep;
- }
- return account.requiresTwoFactorAuth ? CONST.TWO_FACTOR_AUTH_STEPS.ENABLED : CONST.TWO_FACTOR_AUTH_STEPS.CODES;
- }, [account.requiresTwoFactorAuth, account.twoFactorAuthStep]);
+ const [currentStep, setCurrentStep] = useState(CONST.TWO_FACTOR_AUTH_STEPS.CODES);
const {setAnimationDirection} = useAnimatedStepContext();
useEffect(() => () => TwoFactorAuthActions.clearTwoFactorAuthData(), []);
+
+ useEffect(() => {
+ if (account.twoFactorAuthStep) {
+ setCurrentStep(account.twoFactorAuthStep);
+ return;
+ }
+
+ if (account.requiresTwoFactorAuth) {
+ setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.ENABLED);
+ } else {
+ setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.CODES);
+ }
+ }, [account.requiresTwoFactorAuth, account.twoFactorAuthStep]);
+
const handleSetStep = useCallback(
(step, animationDirection = CONST.ANIMATION_DIRECTION.IN) => {
setAnimationDirection(animationDirection);
TwoFactorAuthActions.setTwoFactorAuthStep(step);
+ setCurrentStep(step);
},
[setAnimationDirection],
);
diff --git a/src/pages/settings/Wallet/ChooseTransferAccountPage.js b/src/pages/settings/Wallet/ChooseTransferAccountPage.js
index 15c172d33626..2ffbf12f52c2 100644
--- a/src/pages/settings/Wallet/ChooseTransferAccountPage.js
+++ b/src/pages/settings/Wallet/ChooseTransferAccountPage.js
@@ -37,7 +37,7 @@ function ChooseTransferAccountPage(props) {
* @param {Object} account of the selected account data
*/
const selectAccountAndNavigateBack = (event, accountType, account) => {
- PaymentMethods.saveWalletTransferAccountTypeAndID(accountType, accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? account.bankAccountID : account.fundID);
+ PaymentMethods.saveWalletTransferAccountTypeAndID(accountType, accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? account.bankAccountID : account.fundID);
Navigation.goBack(ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE);
};
@@ -70,7 +70,7 @@ function ChooseTransferAccountPage(props) {