diff --git a/Mobile-Expensify b/Mobile-Expensify index 0194fbea020a..9fa73f2f427b 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 0194fbea020a026b8207cbd11f3ef600ebde0dc5 +Subproject commit 9fa73f2f427b18589ddf76b7b5b380ec5a4519b8 diff --git a/android/app/build.gradle b/android/app/build.gradle index 2758804469a4..ea44e2c8c695 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009007901 - versionName "9.0.79-1" + versionCode 1009007905 + versionName "9.0.79-5" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md index 328b7f2051bc..964664c1d519 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md @@ -13,14 +13,16 @@ Expensify makes it easy to receive bills in three simple ways: Share your Expensify billing email with vendors to receive bills automatically. - Set a Primary Contact under **Settings > Domains > Domain Admins**. -- Ask vendors to email bills to your billing address: `domainname@expensify.cash` (e.g., for *expensify.com*, use `expensify@expensify.cash`). +- Ask vendors to email bills to your billing address: `domainname@expensify.cash` (e.g., for *expensicorp.com*, use `expensicorp@expensify.cash`). - Once emailed, the bill is automatically created in Expensify, ready for payment. +![Setting the Primary Contact at Domain Admins > Primary Contact](https://help.expensify.com/assets/images/OldDot%20-%20Create%20%26%20Pay%20Bills%201.png){:width="100%"} + ### 2. Forwarding Emails Received a bill in your email? Forward it to Expensify. - Ensure your Primary Contact is set under **Settings > Domains > Domain Admins**. -- Forward bills to `domainname@expensify.cash`. Example: `domainname@expensify.cash` (e.g., for *expensify.com*, use `expensify@expensify.cash`). +- Forward bills to `domainname@expensify.cash`. Example: `domainname@expensify.cash` (e.g., for *expensicorp.com*, use `expensicorp@expensify.cash`). - Expensify will create a bill automatically, ready for payment. ### 3. Manual Upload @@ -31,6 +33,7 @@ Got a paper bill? Create a bill manually in [Expensify](https://www.expensify.co 3. Enter the invoice details: sender’s email, merchant name, amount, and date. 4. Upload the invoice as a receipt. +![Manually Create a Bill](https://help.expensify.com/assets/images/OldDot%20-%20Create%20%26%20Pay%20Bills%202.png){:width="100%"} # Paying Bills in Expensify @@ -44,6 +47,8 @@ Expensify makes it easy to manage and pay vendor bills with a straightforward wo 4. **Approval Workflow**: Once reviewed, the bill follows your workspace’s approval process. The final approver handles the payment. 5. **Accounting Integration**: During approval, the bill is coded with the correct GL codes from your connected accounting software. Once approved, it can be exported back to your accounting system. +![Paying a Bill](https://help.expensify.com/assets/images/OldDot%20-%20Create%20%26%20Pay%20Bills%203.png){:width="100%"} + ## Payment Methods Expensify offers several ways to pay bills. Choose the method that works best for you: @@ -92,20 +97,27 @@ If you prefer to pay outside Expensify, you can still track the payment within t 3. Select **Mark as Paid** to update its status. **Fees:** None. + {% include faq-begin.md %} ## Who receives vendor bills in Expensify? -bills are sent to the Primary Contact listed under **Settings > Domains > [Domain Name] > Domain Admins**. + +Bills are sent to the Primary Contact listed under **Settings > Domains > [Domain Name] > Domain Admins**. ## Who can view and pay a bill? + Only the primary domain contact can view and pay a bill. ## How can others access bills? + The primary contact can share bills or grant Copilot access for others to manage payments. ## Is bill Pay supported internationally? + Currently, payments are only supported in USD. ## What's the difference between a bill and an Invoice in Expensify? + A bill represents a payable amount owed to a vendor, while an Invoice is a receivable amount owed to you. + {% include faq-end.md %} diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index cea219fb54d5..7b7186b1720a 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.79.1 + 9.0.79.5 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index cd3532bdd1aa..9a248f24258f 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.79.1 + 9.0.79.5 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index d21333dbec4f..2d93343e851f 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.79 CFBundleVersion - 9.0.79.1 + 9.0.79.5 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index a81d2dd4d581..37c2d785f1b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.79-1", + "version": "9.0.79-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.79-1", + "version": "9.0.79-5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ee510b969f71..ccabc34cffc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.79-1", + "version": "9.0.79-5", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/patches/react-native-reanimated+3.16.4+001+mock-useDerivedValue-getter.patch b/patches/react-native-reanimated+3.16.4+001+mock-useDerivedValue-getter.patch new file mode 100644 index 000000000000..972ddeedf67a --- /dev/null +++ b/patches/react-native-reanimated+3.16.4+001+mock-useDerivedValue-getter.patch @@ -0,0 +1,18 @@ +diff --git a/node_modules/react-native-reanimated/src/mock.ts b/node_modules/react-native-reanimated/src/mock.ts +index 3d8e3f8..5eba613 100644 +--- a/node_modules/react-native-reanimated/src/mock.ts ++++ b/node_modules/react-native-reanimated/src/mock.ts +@@ -87,7 +87,12 @@ const hook = { + useAnimatedReaction: NOOP, + useAnimatedRef: () => ({ current: null }), + useAnimatedScrollHandler: NOOP_FACTORY, +- useDerivedValue: (processor: () => Value) => ({ value: processor() }), ++ // https://github.com/software-mansion/react-native-reanimated/pull/6809 ++ useDerivedValue: (processor: () => Value) => { ++ const result = processor(); ++ ++ return { value: result, get: () => result }; ++ }, + useAnimatedSensor: () => ({ + sensor: { + value: { diff --git a/src/App.tsx b/src/App.tsx index cc824b78fa4c..5de99365aadb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; +import * as ActionSheetAwareScrollView from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; import ActiveWorkspaceContextProvider from './components/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; @@ -89,6 +90,7 @@ function App({url}: AppProps) { CustomStatusBarAndBackgroundContextProvider, ActiveElementRoleProvider, ActiveWorkspaceContextProvider, + ActionSheetAwareScrollView.ActionSheetAwareScrollViewProvider, ReportIDsContextProvider, PlaybackContextProvider, FullScreenContextProvider, diff --git a/src/CONFIG.ts b/src/CONFIG.ts index e5e9a9d1540a..72f98c0ee106 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -104,4 +104,5 @@ export default { // to read more about StrictMode see: contributingGuides/STRICT_MODE.md USE_REACT_STRICT_MODE_IN_DEV: false, ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', + IS_TEST_ENV: process.env.NODE_ENV === 'test', } as const; diff --git a/src/CONST.ts b/src/CONST.ts index cf9e5d8a2886..f295a375e1a6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -665,6 +665,7 @@ const CONST = { HANG_TIGHT: 4, }, }, + BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX: 'accountHolder', }, INCORPORATION_TYPES: { LLC: 'LLC', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 026ab2310622..b45a32821065 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -458,6 +458,9 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /* Corpay fieds to be used in the bank account creation setup */ + CORPAY_FIELDS: 'corpayFields', + /** The user's session that will be preserved when using imported state */ PRESERVED_USER_SESSION: 'preservedUserSession', @@ -1027,6 +1030,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; + [ONYXKEYS.CORPAY_FIELDS]: OnyxTypes.CorpayFields; [ONYXKEYS.PRESERVED_USER_SESSION]: OnyxTypes.Session; [ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING]: OnyxTypes.DismissedProductTraining; }; diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx new file mode 100644 index 000000000000..6fd9914c70e1 --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -0,0 +1,140 @@ +import noop from 'lodash/noop'; +import PropTypes from 'prop-types'; +import type {PropsWithChildren} from 'react'; +import React, {createContext, useMemo} from 'react'; +import type {SharedValue} from 'react-native-reanimated'; +import type {ActionWithPayload, State} from '@hooks/useWorkletStateMachine'; +import useWorkletStateMachine from '@hooks/useWorkletStateMachine'; + +type MeasuredElements = { + fy?: number; + popoverHeight?: number; + height?: number; + composerHeight?: number; +}; + +type Context = { + currentActionSheetState: SharedValue>; + transitionActionSheetState: (action: ActionWithPayload) => void; + transitionActionSheetStateWorklet: (action: ActionWithPayload) => void; + resetStateMachine: () => void; +}; + +/** Holds all information that are needed to coordinate the state value for the action sheet state machine. */ +const currentActionSheetStateValue = { + previous: { + state: 'idle', + payload: null, + }, + current: { + state: 'idle', + payload: null, + }, +}; +const defaultValue: Context = { + currentActionSheetState: { + value: currentActionSheetStateValue, + addListener: noop, + removeListener: noop, + modify: noop, + get: () => currentActionSheetStateValue, + set: noop, + }, + transitionActionSheetState: noop, + transitionActionSheetStateWorklet: noop, + resetStateMachine: noop, +}; + +const ActionSheetAwareScrollViewContext = createContext(defaultValue); + +const Actions = { + OPEN_KEYBOARD: 'KEYBOARD_OPEN', + CLOSE_KEYBOARD: 'CLOSE_KEYBOARD', + OPEN_POPOVER: 'OPEN_POPOVER', + CLOSE_POPOVER: 'CLOSE_POPOVER', + MEASURE_POPOVER: 'MEASURE_POPOVER', + MEASURE_COMPOSER: 'MEASURE_COMPOSER', + POPOVER_ANY_ACTION: 'POPOVER_ANY_ACTION', + HIDE_WITHOUT_ANIMATION: 'HIDE_WITHOUT_ANIMATION', + END_TRANSITION: 'END_TRANSITION', +}; + +const States = { + IDLE: 'idle', + KEYBOARD_OPEN: 'keyboardOpen', + POPOVER_OPEN: 'popoverOpen', + POPOVER_CLOSED: 'popoverClosed', + KEYBOARD_POPOVER_CLOSED: 'keyboardPopoverClosed', + KEYBOARD_POPOVER_OPEN: 'keyboardPopoverOpen', + KEYBOARD_CLOSED_POPOVER: 'keyboardClosingPopover', + POPOVER_MEASURED: 'popoverMeasured', + MODAL_WITH_KEYBOARD_OPEN_DELETED: 'modalWithKeyboardOpenDeleted', +}; + +const STATE_MACHINE = { + [States.IDLE]: { + [Actions.OPEN_POPOVER]: States.POPOVER_OPEN, + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.MEASURE_POPOVER]: States.IDLE, + [Actions.MEASURE_COMPOSER]: States.IDLE, + }, + [States.POPOVER_OPEN]: { + [Actions.CLOSE_POPOVER]: States.POPOVER_CLOSED, + [Actions.MEASURE_POPOVER]: States.POPOVER_OPEN, + [Actions.MEASURE_COMPOSER]: States.POPOVER_OPEN, + [Actions.POPOVER_ANY_ACTION]: States.POPOVER_CLOSED, + [Actions.HIDE_WITHOUT_ANIMATION]: States.IDLE, + }, + [States.POPOVER_CLOSED]: { + [Actions.END_TRANSITION]: States.IDLE, + }, + [States.KEYBOARD_OPEN]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.OPEN_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.CLOSE_KEYBOARD]: States.IDLE, + [Actions.MEASURE_COMPOSER]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_POPOVER_OPEN]: { + [Actions.MEASURE_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.CLOSE_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_POPOVER_CLOSED]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_CLOSED_POPOVER]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.END_TRANSITION]: States.KEYBOARD_OPEN, + }, +}; + +function ActionSheetAwareScrollViewProvider(props: PropsWithChildren) { + const {currentState, transition, transitionWorklet, reset} = useWorkletStateMachine(STATE_MACHINE, { + previous: { + state: 'idle', + payload: null, + }, + current: { + state: 'idle', + payload: null, + }, + }); + + const value = useMemo( + () => ({ + currentActionSheetState: currentState, + transitionActionSheetState: transition, + transitionActionSheetStateWorklet: transitionWorklet, + resetStateMachine: reset, + }), + [currentState, reset, transition, transitionWorklet], + ); + + return {props.children}; +} + +ActionSheetAwareScrollViewProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export {ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions, States}; diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx new file mode 100644 index 000000000000..e15ac941a09d --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -0,0 +1,223 @@ +import React, {useContext, useEffect} from 'react'; +import type {ViewProps} from 'react-native'; +import {useKeyboardHandler} from 'react-native-keyboard-controller'; +import Reanimated, {useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, withTiming} from 'react-native-reanimated'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {Actions, ActionSheetAwareScrollViewContext, States} from './ActionSheetAwareScrollViewContext'; + +const KeyboardState = { + UNKNOWN: 0, + OPENING: 1, + OPEN: 2, + CLOSING: 3, + CLOSED: 4, +}; + +const SPRING_CONFIG = { + mass: 3, + stiffness: 1000, + damping: 500, +}; + +const useAnimatedKeyboard = () => { + const state = useSharedValue(KeyboardState.UNKNOWN); + const height = useSharedValue(0); + const lastHeight = useSharedValue(0); + const heightWhenOpened = useSharedValue(0); + + useKeyboardHandler( + { + onStart: (e) => { + 'worklet'; + + // Save the last keyboard height + if (e.height !== 0) { + heightWhenOpened.set(e.height); + height.set(0); + } + height.set(heightWhenOpened.get()); + lastHeight.set(e.height); + state.set(e.height > 0 ? KeyboardState.OPENING : KeyboardState.CLOSING); + }, + onMove: (e) => { + 'worklet'; + + height.set(e.height); + }, + onEnd: (e) => { + 'worklet'; + + state.set(e.height > 0 ? KeyboardState.OPEN : KeyboardState.CLOSED); + height.set(e.height); + }, + }, + [], + ); + + return {state, height, heightWhenOpened}; +}; + +const useSafeAreaPaddings = () => { + const StyleUtils = useStyleUtils(); + const insets = useSafeAreaInsets(); + const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + + return {top: paddingTop, bottom: paddingBottom}; +}; + +function ActionSheetKeyboardSpace(props: ViewProps) { + const styles = useThemeStyles(); + const safeArea = useSafeAreaPaddings(); + const keyboard = useAnimatedKeyboard(); + + // Similar to using `global` in worklet but it's just a local object + const syncLocalWorkletState = useSharedValue(KeyboardState.UNKNOWN); + const {windowHeight} = useWindowDimensions(); + const {currentActionSheetState, transitionActionSheetStateWorklet: transition, resetStateMachine} = useContext(ActionSheetAwareScrollViewContext); + + // Reset state machine when component unmounts + // eslint-disable-next-line arrow-body-style + useEffect(() => { + return () => resetStateMachine(); + }, [resetStateMachine]); + + useAnimatedReaction( + () => keyboard.state.get(), + (lastState) => { + if (lastState === syncLocalWorkletState.get()) { + return; + } + // eslint-disable-next-line react-compiler/react-compiler + syncLocalWorkletState.set(lastState); + + if (lastState === KeyboardState.OPEN) { + transition({type: Actions.OPEN_KEYBOARD}); + } else if (lastState === KeyboardState.CLOSED) { + transition({type: Actions.CLOSE_KEYBOARD}); + } + }, + [], + ); + + const translateY = useDerivedValue(() => { + const {current, previous} = currentActionSheetState.get(); + + // We don't need to run any additional logic. it will always return 0 for idle state + if (current.state === States.IDLE) { + return withSpring(0, SPRING_CONFIG); + } + + const keyboardHeight = keyboard.height.get() === 0 ? 0 : keyboard.height.get() - safeArea.bottom; + + // Sometimes we need to know the last keyboard height + const lastKeyboardHeight = keyboard.heightWhenOpened.get() - safeArea.bottom; + const {popoverHeight = 0, fy, height} = current.payload ?? {}; + const invertedKeyboardHeight = keyboard.state.get() === KeyboardState.CLOSED ? lastKeyboardHeight : 0; + const elementOffset = fy !== undefined && height !== undefined && popoverHeight !== undefined ? fy + safeArea.top + height - (windowHeight - popoverHeight) : 0; + + // when the state is not idle we know for sure we have the previous state + const previousPayload = previous.payload ?? {}; + const previousElementOffset = + previousPayload.fy !== undefined && previousPayload.height !== undefined && previousPayload.popoverHeight !== undefined + ? previousPayload.fy + safeArea.top + previousPayload.height - (windowHeight - previousPayload.popoverHeight) + : 0; + + const isOpeningKeyboard = syncLocalWorkletState.get() === 1; + const isClosingKeyboard = syncLocalWorkletState.get() === 3; + const isClosedKeyboard = syncLocalWorkletState.get() === 4; + + // Depending on the current and sometimes previous state we can return + // either animation or just a value + switch (current.state) { + case States.KEYBOARD_OPEN: { + if (isClosedKeyboard || isOpeningKeyboard) { + return lastKeyboardHeight - keyboardHeight; + } + if (previous.state === States.KEYBOARD_CLOSED_POPOVER || (previous.state === States.KEYBOARD_OPEN && elementOffset < 0)) { + return Math.max(keyboard.heightWhenOpened.get() - keyboard.height.get() - safeArea.bottom, 0) + Math.max(elementOffset, 0); + } + return withSpring(0, SPRING_CONFIG); + } + + case States.POPOVER_CLOSED: { + return withSpring(0, SPRING_CONFIG, () => { + transition({ + type: Actions.END_TRANSITION, + }); + }); + } + + case States.POPOVER_OPEN: { + if (popoverHeight) { + if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { + return withSpring(elementOffset < 0 ? 0 : elementOffset, SPRING_CONFIG); + } + + return withSpring(Math.max(previousElementOffset, 0), SPRING_CONFIG); + } + + return 0; + } + + case States.KEYBOARD_POPOVER_OPEN: { + if (keyboard.state.get() === KeyboardState.OPEN) { + return withSpring(0, SPRING_CONFIG); + } + + const nextOffset = elementOffset + lastKeyboardHeight; + + if (keyboard.state.get() === KeyboardState.CLOSED && nextOffset > invertedKeyboardHeight) { + return withSpring(nextOffset < 0 ? 0 : nextOffset, SPRING_CONFIG); + } + + if (elementOffset < 0) { + return isClosingKeyboard ? 0 : lastKeyboardHeight - keyboardHeight; + } + + return lastKeyboardHeight; + } + + case States.KEYBOARD_CLOSED_POPOVER: { + if (elementOffset < 0) { + transition({type: Actions.END_TRANSITION}); + + return 0; + } + + if (keyboard.state.get() === KeyboardState.CLOSED) { + return elementOffset + lastKeyboardHeight; + } + + if (keyboard.height.get() > 0) { + return keyboard.heightWhenOpened.get() - keyboard.height.get() + elementOffset; + } + + return withTiming(elementOffset + lastKeyboardHeight, { + duration: 0, + }); + } + + default: + return 0; + } + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + paddingTop: translateY.get(), + })); + + return ( + + ); +} + +ActionSheetKeyboardSpace.displayName = 'ActionSheetKeyboardSpace'; + +export default ActionSheetKeyboardSpace; diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx new file mode 100644 index 000000000000..2c40df7e61c6 --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/index.ios.tsx @@ -0,0 +1,31 @@ +import type {PropsWithChildren} from 'react'; +import React, {forwardRef} from 'react'; +import type {ScrollViewProps} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import {ScrollView} from 'react-native'; +import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; +import ActionSheetKeyboardSpace from './ActionSheetKeyboardSpace'; + +const ActionSheetAwareScrollView = forwardRef>((props, ref) => ( + + {props.children} + +)); + +export default ActionSheetAwareScrollView; + +/** + * This function should be used as renderScrollComponent prop for FlatList + * @param props - props that will be passed to the ScrollView from FlatList + * @returns - ActionSheetAwareScrollView + */ +function renderScrollComponent(props: ScrollViewProps) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +export {renderScrollComponent, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions}; diff --git a/src/components/ActionSheetAwareScrollView/index.tsx b/src/components/ActionSheetAwareScrollView/index.tsx new file mode 100644 index 000000000000..d22f991ce4cf --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/index.tsx @@ -0,0 +1,31 @@ +// this whole file is just for other platforms +// iOS version has everything implemented +import type {PropsWithChildren} from 'react'; +import React, {forwardRef} from 'react'; +import type {ScrollViewProps} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import {ScrollView} from 'react-native'; +import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; + +const ActionSheetAwareScrollView = forwardRef>((props, ref) => ( + + {props.children} + +)); + +export default ActionSheetAwareScrollView; + +/** + * This is only used on iOS. On other platforms it's just undefined to be pass a prop to FlatList + * + * This function should be used as renderScrollComponent prop for FlatList + * @param {Object} props - props that will be passed to the ScrollView from FlatList + * @returns {React.ReactElement} - ActionSheetAwareScrollView + */ +const renderScrollComponent = undefined; + +export {renderScrollComponent, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions}; diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 7cf752a61214..46c3ad18a635 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -44,13 +44,16 @@ function ButtonWithDropdownMenu({ shouldUseStyleUtilityForAnchorPosition = false, defaultSelectedIndex = 0, shouldShowSelectedItemCheck = false, + testID, }: ButtonWithDropdownMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [selectedItemIndex, setSelectedItemIndex] = useState(defaultSelectedIndex); const [isMenuVisible, setIsMenuVisible] = useState(false); - const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); + // In tests, skip the popover anchor position calculation. The default values are needed for popover menu to be rendered in tests. + const defaultPopoverAnchorPosition = process.env.NODE_ENV === 'test' ? {horizontal: 100, vertical: 100} : null; + const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(defaultPopoverAnchorPosition); const {windowWidth, windowHeight} = useWindowDimensions(); const dropdownAnchor = useRef(null); // eslint-disable-next-line react-compiler/react-compiler @@ -139,6 +142,7 @@ function ButtonWithDropdownMenu({ iconRight={Expensicons.DownArrow} shouldShowRightIcon={!isSplitButton} isSplitButton={isSplitButton} + testID={testID} /> {isSplitButton && ( diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 766c0df950b4..dbafbc497105 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -108,6 +108,9 @@ type ButtonWithDropdownMenuProps = { /** Whether selected items should be marked as selected */ shouldShowSelectedItemCheck?: boolean; + + /** Used to locate the component in the tests */ + testID?: string; }; export type { diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index cb0fc6e8e8cb..3bfb5a146d05 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -207,6 +207,7 @@ function ConfirmContent({ isPressOnEnterActive={isVisible} large text={confirmText || translate('common.yes')} + accessibilityLabel={confirmText || translate('common.yes')} isDisabled={isOffline && shouldDisableConfirmButtonWhenOffline} /> {shouldShowCancelButton && !shouldReverseStackedButtons && ( diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index 26d1a902b475..a10e7d9fd1f3 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -1,8 +1,9 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {memo, useEffect, useRef} from 'react'; -import type {GestureResponderEvent} from 'react-native'; +import React, {memo, useContext, useEffect, useRef} from 'react'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import type PressableProps from '@components/Pressable/GenericPressable/types'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; import useLocalize from '@hooks/useLocalize'; @@ -20,7 +21,7 @@ type EmojiPickerButtonProps = { emojiPickerID?: string; /** A callback function when the button is pressed */ - onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; + onPress?: PressableProps['onPress']; /** Emoji popup anchor offset shift vertical */ shiftVertical?: number; @@ -31,12 +32,41 @@ type EmojiPickerButtonProps = { }; function EmojiPickerButton({isDisabled = false, emojiPickerID = '', shiftVertical = 0, onPress, onModalHide, onEmojiSelected}: EmojiPickerButtonProps) { + const actionSheetContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const emojiPopoverAnchor = useRef(null); const {translate} = useLocalize(); const isFocused = useIsFocused(); + const openEmojiPicker: PressableProps['onPress'] = (e) => { + if (!isFocused) { + return; + } + + actionSheetContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_KEYBOARD, + }); + + if (!EmojiPickerAction.emojiPickerRef?.current?.isEmojiPickerVisible) { + EmojiPickerAction.showEmojiPicker( + onModalHide, + onEmojiSelected, + emojiPopoverAnchor, + { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + shiftVertical, + }, + () => {}, + emojiPickerID, + ); + } else { + EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); + } + onPress?.(e); + }; + useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); return ( @@ -45,28 +75,7 @@ function EmojiPickerButton({isDisabled = false, emojiPickerID = '', shiftVertica ref={emojiPopoverAnchor} style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} disabled={isDisabled} - onPress={(e) => { - if (!isFocused) { - return; - } - if (!EmojiPickerAction.emojiPickerRef?.current?.isEmojiPickerVisible) { - EmojiPickerAction.showEmojiPicker( - onModalHide, - onEmojiSelected, - emojiPopoverAnchor, - { - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - shiftVertical, - }, - () => {}, - emojiPickerID, - ); - } else { - EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); - } - onPress?.(e); - }} + onPress={openEmojiPicker} id={CONST.EMOJI_PICKER_BUTTON_NATIVE_ID} accessibilityLabel={translate('reportActionCompose.emoji')} > diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 95db3aba7a37..2ad810ee36fa 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -94,7 +94,7 @@ function ImageRenderer({tnode}: ImageRendererProps) { thumbnailImageComponent ) : ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( {({reportID, accountID, type}) => ( + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(reportNameValuePairs)), + ); }} shouldUseHapticsOnLongPress accessibilityRole={CONST.ROLE.BUTTON} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 0b53143ffdac..6bb674dab592 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -84,14 +84,16 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona return ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( { if (isDisabled) { return; } - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(reportNameValuePairs)); + return onShowContextMenu(() => + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(reportNameValuePairs)), + ); }} onPress={(event) => { event.preventDefault(); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 854a934e3337..e10e965ec331 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -34,16 +34,18 @@ function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...d return ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( {})} onPressIn={onPressIn} onPressOut={onPressOut} onLongPress={(event) => { - if (isDisabled) { - return; - } - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(reportNameValuePairs)); + onShowContextMenu(() => { + if (isDisabled) { + return; + } + return showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(reportNameValuePairs)); + }); }} shouldUseHapticsOnLongPress role={CONST.ROLE.PRESENTATION} diff --git a/src/components/KeyboardAvoidingView/index.android.tsx b/src/components/KeyboardAvoidingView/index.android.tsx index ec2dc3bd18d7..e81ebd6ff671 100644 --- a/src/components/KeyboardAvoidingView/index.android.tsx +++ b/src/components/KeyboardAvoidingView/index.android.tsx @@ -1,6 +1,3 @@ -/* - * The KeyboardAvoidingView is only used on ios - */ import React from 'react'; import type {KeyboardAvoidingViewProps} from 'react-native-keyboard-controller'; import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; diff --git a/src/components/KeyboardAvoidingView/index.ios.tsx b/src/components/KeyboardAvoidingView/index.ios.tsx index 171210eab7ac..e81ebd6ff671 100644 --- a/src/components/KeyboardAvoidingView/index.ios.tsx +++ b/src/components/KeyboardAvoidingView/index.ios.tsx @@ -1,9 +1,6 @@ -/* - * The KeyboardAvoidingView is only used on ios - */ import React from 'react'; -import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native'; -import type {KeyboardAvoidingViewProps} from './types'; +import type {KeyboardAvoidingViewProps} from 'react-native-keyboard-controller'; +import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index efdd9659c845..ead50d44634c 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -48,9 +48,9 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${optionItem?.reportID || -1}`); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const isActiveWorkspaceChat = ReportUtils.isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat && activePolicyID === report?.policyID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const session = useSession(); + const isActiveWorkspaceChat = ReportUtils.isPolicyExpenseChat(report) && activePolicyID === report?.policyID && session?.accountID === report?.ownerAccountID; const isOnboardingGuideAssigned = introSelected?.choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !session?.email?.includes('+'); const shouldShowGetStartedTooltip = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report); diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 4100b877e2da..951471fdf76c 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -114,36 +114,44 @@ function MagicCodeInput( ) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const inputRefs = useRef(); + const inputRef = useRef(); const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE); const [focusedIndex, setFocusedIndex] = useState(0); - const [editIndex, setEditIndex] = useState(0); + const editIndex = useRef(0); const [wasSubmitted, setWasSubmitted] = useState(false); const shouldFocusLast = useRef(false); const inputWidth = useRef(0); const lastFocusedIndex = useRef(0); const lastValue = useRef(TEXT_INPUT_EMPTY_STATE); + const valueRef = useRef(value); useEffect(() => { lastValue.current = input.length; }, [input]); + useEffect(() => { + // Note: there are circumstances where the value state isn't updated yet + // when e.g. onChangeText gets called the next time. In those cases its safer to access the value from a ref + // to not have outdated values. + valueRef.current = value; + }, [value]); + const blurMagicCodeInput = () => { - inputRefs.current?.blur(); + inputRef.current?.blur(); setFocusedIndex(undefined); }; const focusMagicCodeInput = () => { setFocusedIndex(0); lastFocusedIndex.current = 0; - setEditIndex(0); - inputRefs.current?.focus(); + editIndex.current = 0; + inputRef.current?.focus(); }; const setInputAndIndex = (index: number) => { setInput(TEXT_INPUT_EMPTY_STATE); setFocusedIndex(index); - setEditIndex(index); + editIndex.current = index; }; useImperativeHandle(ref, () => ({ @@ -151,7 +159,7 @@ function MagicCodeInput( focusMagicCodeInput(); }, focusLastSelected() { - inputRefs.current?.focus(); + inputRef.current?.focus(); }, resetFocus() { setInput(TEXT_INPUT_EMPTY_STATE); @@ -160,7 +168,7 @@ function MagicCodeInput( clear() { lastFocusedIndex.current = 0; setInputAndIndex(0); - inputRefs.current?.focus(); + inputRef.current?.focus(); onChangeTextProp(''); }, blur() { @@ -219,7 +227,7 @@ function MagicCodeInput( // TapGestureHandler works differently on mobile web and native app // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) { - inputRefs.current?.focus(); + inputRef.current?.focus(); } setInputAndIndex(index); lastFocusedIndex.current = index; @@ -231,6 +239,12 @@ function MagicCodeInput( * the focused input on the next empty one, if exists. * It handles both fast typing and only one digit at a time * in a specific position. + * + * Note: this works under the assumption that the backing text input will always have a cleared text, + * and entering text will exactly call onChangeText with one new character/digit. + * When the OS is inserting one time passwords for example it will call this method successively with one more digit each time. + * Thus, this method relies on an internal value ref to make sure to always use the latest value (as state updates are async, and + * might happen later than the next call to onChangeText). */ const onChangeText = (textValue?: string) => { if (!textValue?.length || !ValidationUtils.isNumeric(textValue)) { @@ -249,16 +263,17 @@ function MagicCodeInput( const numbersArr = addedValue .trim() .split('') - .slice(0, maxLength - editIndex); - const updatedFocusedIndex = Math.min(editIndex + (numbersArr.length - 1) + 1, maxLength - 1); + .slice(0, maxLength - editIndex.current); + const updatedFocusedIndex = Math.min(editIndex.current + (numbersArr.length - 1) + 1, maxLength - 1); - let numbers = decomposeString(value, maxLength); - numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, maxLength)]; + let numbers = decomposeString(valueRef.current, maxLength); + numbers = [...numbers.slice(0, editIndex.current), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex.current, maxLength)]; setInputAndIndex(updatedFocusedIndex); const finalInput = composeToString(numbers); onChangeTextProp(finalInput); + valueRef.current = finalInput; }; /** @@ -275,12 +290,13 @@ function MagicCodeInput( // If keyboard is disabled and no input is focused we need to remove // the last entered digit and focus on the correct input if (isDisableKeyboard && focusedIndex === undefined) { - const indexBeforeLastEditIndex = editIndex === 0 ? editIndex : editIndex - 1; + const curEditIndex = editIndex.current; + const indexBeforeLastEditIndex = curEditIndex === 0 ? curEditIndex : curEditIndex - 1; - const indexToFocus = numbers.at(editIndex) === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : editIndex; + const indexToFocus = numbers.at(curEditIndex) === CONST.MAGIC_CODE_EMPTY_CHAR ? indexBeforeLastEditIndex : curEditIndex; if (indexToFocus !== undefined) { lastFocusedIndex.current = indexToFocus; - inputRefs.current?.focus(); + inputRef.current?.focus(); } onChangeTextProp(value.substring(0, indexToFocus)); @@ -292,7 +308,7 @@ function MagicCodeInput( if (focusedIndex !== undefined && numbers?.at(focusedIndex) !== CONST.MAGIC_CODE_EMPTY_CHAR) { setInput(TEXT_INPUT_EMPTY_STATE); numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, maxLength)]; - setEditIndex(focusedIndex); + editIndex.current = focusedIndex; onChangeTextProp(composeToString(numbers)); return; } @@ -318,17 +334,17 @@ function MagicCodeInput( if (newFocusedIndex !== undefined) { lastFocusedIndex.current = newFocusedIndex; - inputRefs.current?.focus(); + inputRef.current?.focus(); } } if (keyValue === 'ArrowLeft' && focusedIndex !== undefined) { const newFocusedIndex = Math.max(0, focusedIndex - 1); setInputAndIndex(newFocusedIndex); - inputRefs.current?.focus(); + inputRef.current?.focus(); } else if (keyValue === 'ArrowRight' && focusedIndex !== undefined) { const newFocusedIndex = Math.min(focusedIndex + 1, maxLength - 1); setInputAndIndex(newFocusedIndex); - inputRefs.current?.focus(); + inputRef.current?.focus(); } else if (keyValue === 'Enter') { // We should prevent users from submitting when it's offline. if (isOffline) { @@ -340,7 +356,7 @@ function MagicCodeInput( const newFocusedIndex = (event as unknown as KeyboardEvent).shiftKey ? focusedIndex - 1 : focusedIndex + 1; if (newFocusedIndex >= 0 && newFocusedIndex < maxLength) { setInputAndIndex(newFocusedIndex); - inputRefs.current?.focus(); + inputRef.current?.focus(); if (event?.preventDefault) { event.preventDefault(); } @@ -383,7 +399,7 @@ function MagicCodeInput( onLayout={(e) => { inputWidth.current = e.nativeEvent.layout.width; }} - ref={(inputRef) => (inputRefs.current = inputRef)} + ref={(newRef) => (inputRef.current = newRef)} autoFocus={autoFocus} inputMode="numeric" textContentType="oneTimeCode" diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 19703f7a3c92..59e7b78feeda 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -348,6 +348,9 @@ type MenuItemBaseProps = { /** Should break word for room title */ shouldBreakWord?: boolean; + + /** Pressable component Test ID. Used to locate the component in tests. */ + pressableTestID?: string; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -461,6 +464,7 @@ function MenuItem( onHideTooltip, shouldIconUseAutoWidthStyle = false, shouldBreakWord = false, + pressableTestID, }: MenuItemProps, ref: PressableRef, ) { @@ -610,6 +614,7 @@ function MenuItem( wrapperStyle={outerWrapperStyle} activeOpacity={variables.pressDimValue} opacityAnimationDuration={0} + testID={pressableTestID} style={({pressed}) => [ containerStyle, diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 51cb2a6d6f39..6c467145cc3c 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -276,6 +276,7 @@ function MoneyRequestConfirmationListFooter({ reportNameValuePairs: undefined, action: undefined, checkIfContextMenuActive: () => {}, + onShowContextMenu: () => {}, isDisabled: true, }), [], diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 7432c683e0a7..9ee06ad57e1d 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -3,7 +3,7 @@ import lodashIsEqual from 'lodash/isEqual'; import type {ReactNode, RefObject} from 'react'; import React, {useLayoutEffect, useState} from 'react'; import {StyleSheet, View} from 'react-native'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -62,6 +62,9 @@ type PopoverMenuProps = Partial & { /** Callback method fired when the user requests to close the modal */ onClose: () => void; + /** Optional callback passed to popover's children container */ + onLayout?: (e: LayoutChangeEvent) => void; + /** Callback method fired when the modal is shown */ onModalShow?: () => void; @@ -130,6 +133,9 @@ type PopoverMenuProps = Partial & { /** Should we apply padding style in modal itself. If this value is false, we will handle it in ScreenWrapper */ shouldUseModalPaddingStyle?: boolean; + + /** Used to locate the component in the tests */ + testID?: string; }; const renderWithConditionalWrapper = (shouldUseScrollView: boolean, contentContainerStyle: StyleProp, children: ReactNode): React.JSX.Element => { @@ -151,6 +157,7 @@ function PopoverMenu({ anchorPosition, anchorRef, onClose, + onLayout, onModalShow, headerText, fromSidebarMediumScreen, @@ -174,6 +181,7 @@ function PopoverMenu({ shouldUseScrollView = false, shouldUpdateFocusedIndex = true, shouldUseModalPaddingStyle, + testID, }: PopoverMenuProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -261,6 +269,7 @@ function PopoverMenu({ selectItem(menuIndex)} focused={focusedIndex === menuIndex} @@ -357,9 +366,13 @@ function PopoverMenu({ restoreFocusType={restoreFocusType} innerContainerStyle={innerContainerStyle} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} + testID={testID} > - + {renderHeaderText()} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {renderWithConditionalWrapper(shouldUseScrollView, scrollContainerStyle, renderedMenuItems)} diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index 4fa58ac21ffa..80b9fb1a9564 100644 --- a/src/components/PopoverWithMeasuredContent.tsx +++ b/src/components/PopoverWithMeasuredContent.tsx @@ -1,5 +1,5 @@ import isEqual from 'lodash/isEqual'; -import React, {useMemo, useState} from 'react'; +import React, {useContext, useMemo, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -8,6 +8,7 @@ import ComposerFocusManager from '@libs/ComposerFocusManager'; import PopoverWithMeasuredContentUtils from '@libs/PopoverWithMeasuredContentUtils'; import CONST from '@src/CONST'; import type {AnchorDimensions, AnchorPosition} from '@src/styles'; +import * as ActionSheetAwareScrollView from './ActionSheetAwareScrollView'; import Popover from './Popover'; import type PopoverProps from './Popover/types'; @@ -61,6 +62,7 @@ function PopoverWithMeasuredContent({ shouldEnableNewFocusManagement, ...props }: PopoverWithMeasuredContentProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); const [popoverWidth, setPopoverWidth] = useState(popoverDimensions.width); @@ -89,9 +91,22 @@ function PopoverWithMeasuredContent({ * Measure the size of the popover's content. */ const measurePopover = ({nativeEvent}: LayoutChangeEvent) => { - setPopoverWidth(nativeEvent.layout.width); - setPopoverHeight(nativeEvent.layout.height); + const {width, height} = nativeEvent.layout; + setPopoverWidth(width); + setPopoverHeight(height); setIsContentMeasured(true); + + // it handles the case when `measurePopover` is called with values like: 192, 192.00003051757812, 192 + // if we update it, then animation in `ActionSheetAwareScrollView` may be re-running + // and we'll see unsynchronized and junky animation + if (actionSheetAwareScrollViewContext.currentActionSheetState.get().current.payload?.popoverHeight !== Math.floor(height) && height !== 0) { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, + payload: { + popoverHeight: Math.floor(height), + }, + }); + } }; const adjustedAnchorPosition = useMemo(() => { diff --git a/src/components/ReportActionItem/MoneyRequestAction.tsx b/src/components/ReportActionItem/MoneyRequestAction.tsx index af54e2940d3f..e17c30e8bddb 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.tsx +++ b/src/components/ReportActionItem/MoneyRequestAction.tsx @@ -51,6 +51,9 @@ type MoneyRequestActionProps = MoneyRequestActionOnyxProps & { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; + /** Callback for measuring child and running a defined callback/action later */ + onShowContextMenu?: (callback: () => void) => void; + /** Whether the IOU is hovered so we can modify its style */ isHovered?: boolean; @@ -71,6 +74,7 @@ function MoneyRequestAction({ reportID, isMostRecentIOUReportAction, contextMenuAnchor, + onShowContextMenu = () => {}, checkIfContextMenuActive = () => {}, chatReport, iouReport, @@ -129,6 +133,7 @@ function MoneyRequestAction({ isTrackExpense={isTrackExpenseAction} action={action} contextMenuAnchor={contextMenuAnchor} + onShowContextMenu={onShowContextMenu} checkIfContextMenuActive={checkIfContextMenuActive} shouldShowPendingConversionMessage={shouldShowPendingConversionMessage} onPreviewPressed={onMoneyRequestPreviewPressed} diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 86196f13d662..4cff7abe6d0f 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -60,6 +60,7 @@ function MoneyRequestPreviewContent({ onPreviewPressed, containerStyles, checkIfContextMenuActive = () => {}, + onShowContextMenu = () => {}, shouldShowPendingConversionMessage = false, isHovered = false, isWhisper = false, @@ -189,7 +190,7 @@ function MoneyRequestPreviewContent({ if (!shouldDisplayContextMenu) { return; } - showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive); + onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive)); }; const getPreviewHeaderText = (): string => { diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index c40b45c6d2bd..7f19120426c1 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -27,6 +27,9 @@ type MoneyRequestPreviewProps = { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; + /** Callback for measuring child and running a defined callback/action later */ + onShowContextMenu?: (callback: () => void) => void; + /** Extra styles to pass to View wrapper */ containerStyles?: StyleProp; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index a4ade8d77aa8..3b159c2e4fd5 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -70,6 +70,9 @@ type ReportPreviewProps = { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; + /** Callback for measuring child and running a defined callback/action later */ + onShowContextMenu: (callback: () => void) => void; + /** Callback when the payment options popover is shown */ onPaymentOptionsShow?: () => void; @@ -95,6 +98,7 @@ function ReportPreview({ checkIfContextMenuActive = () => {}, onPaymentOptionsShow, onPaymentOptionsHide, + onShowContextMenu = () => {}, }: ReportPreviewProps) { const policy = usePolicy(policyID); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); @@ -492,7 +496,7 @@ function ReportPreview({ onPress={openReportFromPreview} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} + onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive))} shouldUseHapticsOnLongPress style={[styles.flexRow, styles.justifyContentBetween, styles.reportPreviewBox]} role="button" diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index 2ea295d16143..8c6cf3d43e3f 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -57,11 +57,24 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: () => void; + /** Callback that will do measure of necessary layout elements and run provided callback */ + onShowContextMenu: (callback: () => void) => void; + /** Style for the task preview container */ style: StyleProp; }; -function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, isHovered = false, style}: TaskPreviewProps) { +function TaskPreview({ + taskReportID, + action, + contextMenuAnchor, + chatReportID, + checkIfContextMenuActive, + currentUserPersonalDetails, + onShowContextMenu, + isHovered = false, + style, +}: TaskPreviewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -96,7 +109,7 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(taskReportID))} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} + onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive))} shouldUseHapticsOnLongPress style={[styles.flexRow, styles.justifyContentBetween, style]} role={CONST.ROLE.BUTTON} diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx index d6ce930d0ec7..df9c3f9280d7 100644 --- a/src/components/SelectionList/ChatListItem.tsx +++ b/src/components/SelectionList/ChatListItem.tsx @@ -51,6 +51,7 @@ function ChatListItem({ action: undefined, transactionThreadReport: undefined, checkIfContextMenuActive: () => {}, + onShowContextMenu: () => {}, isDisabled: true, }; diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 8b27ee8a20f8..7c11a55a7b7f 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -89,6 +89,7 @@ function TableListItem({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing disabled={isDisabled || item.isDisabledCheckbox} onPress={handleCheckboxPress} + testID={`TableListItemCheckbox-${item.text}`} style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3, item.cursorStyle]} > diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index 6fefa987fac3..ee6e7e71dd7a 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -16,11 +16,13 @@ type ShowContextMenuContextProps = { action: OnyxEntry; transactionThreadReport?: OnyxEntry; checkIfContextMenuActive: () => void; + onShowContextMenu: (callback: () => void) => void; isDisabled: boolean; }; const ShowContextMenuContext = createContext({ anchor: null, + onShowContextMenu: (callback) => callback(), report: undefined, reportNameValuePairs: undefined, action: undefined, @@ -62,7 +64,7 @@ function showContextMenuForReport( action?.reportActionID, ReportUtils.getOriginalReportID(reportID, action), undefined, - checkIfContextMenuActive, + undefined, checkIfContextMenuActive, isArchivedRoom, ); diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index 9bd37522c5c1..7cc451809ee5 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -61,12 +61,15 @@ function BaseTextInput( multiline = false, autoCorrect = true, prefixCharacter = '', + suffixCharacter = '', inputID, isMarkdownEnabled = false, excludedMarkdownStyles = [], shouldShowClearButton = false, prefixContainerStyle = [], prefixStyle = [], + suffixContainerStyle = [], + suffixStyle = [], contentWidth, loadingSpinnerStyle, ...props @@ -87,7 +90,7 @@ function BaseTextInput( // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const initialValue = value || defaultValue || ''; - const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter; + const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter || !!suffixCharacter; const isMultiline = multiline || autoGrowHeight; const [isFocused, setIsFocused] = useState(false); @@ -141,13 +144,13 @@ function BaseTextInput( const deactivateLabel = useCallback(() => { const inputValue = value ?? ''; - if (!!forceActiveLabel || inputValue.length !== 0 || prefixCharacter) { + if (!!forceActiveLabel || inputValue.length !== 0 || prefixCharacter || suffixCharacter) { return; } animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE); isLabelActive.current = false; - }, [animateLabel, forceActiveLabel, prefixCharacter, value]); + }, [animateLabel, forceActiveLabel, prefixCharacter, suffixCharacter, value]); const onFocus = (event: NativeSyntheticEvent) => { inputProps.onFocus?.(event); @@ -250,7 +253,7 @@ function BaseTextInput( // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const inputHelpText = errorText || hint; - const placeholderValue = !!prefixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined; + const placeholderValue = !!prefixCharacter || !!suffixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined; const newTextInputContainerStyles: StyleProp = StyleSheet.flatten([ styles.textInputContainer, textInputContainerStyles, @@ -263,7 +266,7 @@ function BaseTextInput( ]); const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft); - + const inputPaddingRight = !!suffixCharacter && StyleUtils.getPaddingRight(StyleUtils.getCharacterPadding(suffixCharacter) + styles.pr1.paddingRight); return ( <> @@ -351,6 +354,7 @@ function BaseTextInput( inputStyle, (!hasLabel || isMultiline) && styles.pv0, inputPaddingLeft, + inputPaddingRight, inputProps.secureTextEntry && styles.secureInput, !isMultiline && {height, lineHeight: undefined}, @@ -380,6 +384,17 @@ function BaseTextInput( defaultValue={defaultValue} markdownStyle={markdownStyle} /> + {!!suffixCharacter && ( + + + {suffixCharacter} + + + )} {isFocused && !isReadOnly && shouldShowClearButton && !!value && setValue('')} />} {!!inputProps.isLoading && ( { + const hidePopoverMenu = useCallback(() => { setPopupMenuVisible(false); - }; + }, []); useEffect(() => { if (!isBehindModal || !isPopupMenuVisible) { return; } hidePopoverMenu(); - }, [isBehindModal, isPopupMenuVisible]); + }, [hidePopoverMenu, isBehindModal, isPopupMenuVisible]); return ( <> diff --git a/src/components/ValidateCodeActionModal/index.tsx b/src/components/ValidateCodeActionModal/index.tsx index d0e00493e328..c25401d33494 100644 --- a/src/components/ValidateCodeActionModal/index.tsx +++ b/src/components/ValidateCodeActionModal/index.tsx @@ -49,7 +49,10 @@ function ValidateCodeActionModal({ firstRenderRef.current = false; sendValidateCode(); - }, [isVisible, sendValidateCode, hasMagicCodeBeenSent]); + // We only want to send validate code on first render not on change of hasMagicCodeBeenSent, so we don't add it as a dependency. + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisible, sendValidateCode]); return ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, onShowContextMenu}) => ( { + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(reportNameValuePairs)); + }); }} shouldUseHapticsOnLongPress > diff --git a/src/hooks/useRestoreInputFocus.ts b/src/hooks/useRestoreInputFocus/index.android.ts similarity index 100% rename from src/hooks/useRestoreInputFocus.ts rename to src/hooks/useRestoreInputFocus/index.android.ts diff --git a/src/hooks/useRestoreInputFocus/index.ts b/src/hooks/useRestoreInputFocus/index.ts new file mode 100644 index 000000000000..4105455698dc --- /dev/null +++ b/src/hooks/useRestoreInputFocus/index.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const useRestoreInputFocus = (_isLostFocus: boolean) => {}; + +export default useRestoreInputFocus; diff --git a/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts new file mode 100644 index 000000000000..eab78097aa05 --- /dev/null +++ b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts @@ -0,0 +1,3 @@ +import {executeOnUIRuntimeSync} from 'react-native-reanimated'; + +export default executeOnUIRuntimeSync; diff --git a/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts new file mode 100644 index 000000000000..3bc8059d8762 --- /dev/null +++ b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts @@ -0,0 +1,3 @@ +import {runOnUI} from 'react-native-reanimated'; + +export default runOnUI; diff --git a/src/hooks/useWorkletStateMachine/index.ts b/src/hooks/useWorkletStateMachine/index.ts new file mode 100644 index 000000000000..cfaffe968370 --- /dev/null +++ b/src/hooks/useWorkletStateMachine/index.ts @@ -0,0 +1,180 @@ +import {useCallback} from 'react'; +import {runOnJS, runOnUI, useSharedValue} from 'react-native-reanimated'; +import Log from '@libs/Log'; +import executeOnUIRuntimeSync from './executeOnUIRuntimeSync'; + +// When you need to debug state machine change this to true +const DEBUG_MODE = false; + +type Payload = Record; +type ActionWithPayload

= { + type: string; + payload?: P; +}; +type StateHolder

= { + state: string; + payload: P | null; +}; +type State

= { + previous: StateHolder

; + current: StateHolder

; +}; + +/** + * Represents the state machine configuration as a nested record where: + * - The first level keys are the state names. + * - The second level keys are the action types valid for that state. + * - The corresponding values are the next states to transition to when the action is triggered. + */ +type StateMachine = Record>; + +// eslint-disable-next-line @typescript-eslint/unbound-method +const client = Log.client; + +/** + * A hook that creates a state machine that can be used with Reanimated Worklets. + * You can transition state from worklet or from the JS thread. + * + * State machines are helpful for managing complex UI interactions. We want to transition + * between states based on user actions. But also we want to ignore some actions + * when we are in certain states. + * + * For example: + * 1. Initial state is idle. It can react to KEYBOARD_OPEN action. + * 2. We open emoji picker. It sends EMOJI_PICKER_OPEN action. + * 2. There is no handling for this action in idle state so we do nothing. + * 3. We close emoji picker and it sends EMOJI_PICKER_CLOSE action which again does nothing. + * 4. We open keyboard. It sends KEYBOARD_OPEN action. idle can react to this action + * by transitioning into keyboardOpen state + * 5. Our state is keyboardOpen. It can react to KEYBOARD_CLOSE, EMOJI_PICKER_OPEN actions + * 6. We open emoji picker again. It sends EMOJI_PICKER_OPEN action which transitions our state + * into emojiPickerOpen state. Now we react only to EMOJI_PICKER_CLOSE action. + * 7. Before rendering the emoji picker, the app hides the keyboard. + * It sends KEYBOARD_CLOSE action. But we ignore it since our emojiPickerOpen state can only handle + * EMOJI_PICKER_CLOSE action. So we write the logic for handling hiding the keyboard, + * but maintaining the offset based on the keyboard state shared value + * 7. We close the picker and send EMOJI_PICKER_CLOSE action which transitions us back into keyboardOpen state. + * + * State machine object example: + * const stateMachine = { + * idle: { + * KEYBOARD_OPEN: 'keyboardOpen', + * }, + * keyboardOpen: { + * KEYBOARD_CLOSE: 'idle', + * EMOJI_PICKER_OPEN: 'emojiPickerOpen', + * }, + * emojiPickerOpen: { + * EMOJI_PICKER_CLOSE: 'keyboardOpen', + * }, + * } + * + * Initial state example: + * { + * previous: null, + * current: { + * state: 'idle', + * payload: null, + * }, + * } + * + * @param stateMachine - a state machine object + * @param initialState - the initial state of the state machine + * @returns an object containing the current state, a transition function, and a reset function + */ +function useWorkletStateMachine

(stateMachine: StateMachine, initialState: State

) { + const currentState = useSharedValue(initialState); + + const log = useCallback((message: string, params?: P | null) => { + 'worklet'; + + if (!DEBUG_MODE) { + return; + } + + // eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/restrict-template-expressions + runOnJS(client)(`[StateMachine] ${message}. Params: ${JSON.stringify(params)}`); + }, []); + + const transitionWorklet = useCallback( + (action: ActionWithPayload

) => { + 'worklet'; + + if (!action) { + throw new Error('state machine action is required'); + } + + const state = currentState.get(); + + log(`Current STATE: ${state.current.state}`); + log(`Next ACTION: ${action.type}`, action.payload); + + const nextMachine = stateMachine[state.current.state]; + + if (!nextMachine) { + log(`No next machine found for state: ${state.current.state}`); + return; + } + + const nextState = nextMachine[action.type]; + + if (!nextState) { + log(`No next state found for action: ${action.type}`); + return; + } + + let nextPayload; + + if (typeof action.payload === 'undefined') { + // we save previous payload + nextPayload = state.current.payload; + } else { + // we merge previous payload with the new payload + nextPayload = { + ...state.current.payload, + ...action.payload, + }; + } + + log(`Next STATE: ${nextState}`, nextPayload); + + currentState.set({ + previous: state.current, + current: { + state: nextState, + payload: nextPayload, + }, + }); + }, + [currentState, log, stateMachine], + ); + + const resetWorklet = useCallback(() => { + 'worklet'; + + log('RESET STATE MACHINE'); + // eslint-disable-next-line react-compiler/react-compiler + currentState.set(initialState); + }, [currentState, initialState, log]); + + const reset = useCallback(() => { + runOnUI(resetWorklet)(); + }, [resetWorklet]); + + const transition = useCallback( + (action: ActionWithPayload

) => { + executeOnUIRuntimeSync(transitionWorklet)(action); + }, + [transitionWorklet], + ); + + return { + currentState, + transitionWorklet, + transition, + reset, + }; +} + +export type {ActionWithPayload, State}; +export default useWorkletStateMachine; diff --git a/src/languages/en.ts b/src/languages/en.ts index 3d35fed3e0a4..5d9f25404d88 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -517,6 +517,7 @@ const translations = { chooseDocument: 'Choose file', attachmentTooLarge: 'Attachment is too large', sizeExceeded: 'Attachment size is larger than 24 MB limit', + sizeExceededWithLimit: ({maxUploadSizeInMB}: SizeExceededParams) => `Attachment size is larger than ${maxUploadSizeInMB} MB limit`, attachmentTooSmall: 'Attachment is too small', sizeNotMet: 'Attachment size must be greater than 240 bytes', wrongFileType: 'Invalid file type', @@ -1753,6 +1754,7 @@ const translations = { }, onboarding: { welcome: 'Welcome!', + welcomeSignOffTitle: "It's great to meet you!", explanationModal: { title: 'Welcome to Expensify', description: 'One app to handle your business and personal spend at the speed of chat. Try it out and let us know what you think. Much more to come!', diff --git a/src/languages/es.ts b/src/languages/es.ts index cb7f53424958..3ce50b3c5bf7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -512,6 +512,7 @@ const translations = { chooseDocument: 'Elegir un archivo', attachmentTooLarge: 'Archivo adjunto demasiado grande', sizeExceeded: 'El archivo adjunto supera el límite de 24 MB.', + sizeExceededWithLimit: ({maxUploadSizeInMB}: SizeExceededParams) => `El archivo adjunto supera el límite de ${maxUploadSizeInMB} MB.`, attachmentTooSmall: 'Archivo adjunto demasiado pequeño', sizeNotMet: 'El archivo adjunto debe ser más grande que 240 bytes.', wrongFileType: 'Tipo de archivo inválido', @@ -1756,6 +1757,7 @@ const translations = { }, onboarding: { welcome: '¡Bienvenido!', + welcomeSignOffTitle: '¡Es un placer conocerte!', explanationModal: { title: 'Bienvenido a Expensify', description: 'Una aplicación para gestionar en un chat todos los gastos de tu empresa y personales. Inténtalo y dinos qué te parece. ¡Hay mucho más por venir!', diff --git a/src/libs/API/parameters/BankAccountCreateCorpayParams.ts b/src/libs/API/parameters/BankAccountCreateCorpayParams.ts new file mode 100644 index 000000000000..3c617d326009 --- /dev/null +++ b/src/libs/API/parameters/BankAccountCreateCorpayParams.ts @@ -0,0 +1,8 @@ +type BankAccountCreateCorpayParams = { + type: number; + isSavings: boolean; + isWithdrawal: boolean; + inputs: string; +}; + +export default BankAccountCreateCorpayParams; diff --git a/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts b/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts new file mode 100644 index 000000000000..3e02b57f9e12 --- /dev/null +++ b/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts @@ -0,0 +1,8 @@ +type GetCorpayBankAccountFieldsParams = { + countryISO: string; + currency: string; + isWithdrawal: boolean; + isBusinessBankAccount: boolean; +}; + +export default GetCorpayBankAccountFieldsParams; diff --git a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts index 6ef6b3712439..5b7a221a8702 100644 --- a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts +++ b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts @@ -1,6 +1,6 @@ type VerifyIdentityForBankAccountParams = { bankAccountID: number; onfidoData: string; - policyID: string; + policyID?: string; }; export default VerifyIdentityForBankAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 7e8b8cec520b..ea2d9893cf24 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -8,6 +8,7 @@ export type {default as RestartBankAccountSetupParams} from './RestartBankAccoun export type {default as AddSchoolPrincipalParams} from './AddSchoolPrincipalParams'; export type {default as AuthenticatePusherParams} from './AuthenticatePusherParams'; export type {default as BankAccountHandlePlaidErrorParams} from './BankAccountHandlePlaidErrorParams'; +export type {default as BankAccountCreateCorpayParams} from './BankAccountCreateCorpayParams'; export type {default as BeginAppleSignInParams} from './BeginAppleSignInParams'; export type {default as BeginGoogleSignInParams} from './BeginGoogleSignInParams'; export type {default as BeginSignInParams} from './BeginSignInParams'; @@ -29,6 +30,7 @@ export type {default as ExpandURLPreviewParams} from './ExpandURLPreviewParams'; export type {default as GetMissingOnyxMessagesParams} from './GetMissingOnyxMessagesParams'; export type {default as GetNewerActionsParams} from './GetNewerActionsParams'; export type {default as GetOlderActionsParams} from './GetOlderActionsParams'; +export type {default as GetCorpayBankAccountFieldsParams} from './GetCorpayBankAccountFieldsParams'; export type {default as GetPolicyCategoriesParams} from './GetPolicyCategories'; export type {default as GetReportPrivateNoteParams} from './GetReportPrivateNoteParams'; export type {default as GetRouteParams} from './GetRouteParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index aa9831ca4053..7b8c6df92a22 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -440,6 +440,7 @@ const WRITE_COMMANDS = { SELF_TOUR_VIEWED: 'SelfTourViewed', UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName', UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite', + BANK_ACCOUNT_CREATE_CORPAY: 'BankAccount_CreateCorpay', UPDATE_WORKSPACE_CUSTOM_UNIT: 'UpdateWorkspaceCustomUnit', VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES: 'ValidateUserAndGetAccessiblePolicies', DISMISS_PRODUCT_TRAINING: 'DismissProductTraining', @@ -774,6 +775,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.HoldMoneyRequestOnSearchParams; [WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH]: Parameters.ApproveMoneyRequestOnSearchParams; [WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.UnholdMoneyRequestOnSearchParams; + [WRITE_COMMANDS.BANK_ACCOUNT_CREATE_CORPAY]: Parameters.BankAccountCreateCorpayParams; [WRITE_COMMANDS.REQUEST_REFUND]: null; [WRITE_COMMANDS.CONNECT_POLICY_TO_SAGE_INTACCT]: Parameters.ConnectPolicyToSageIntacctParams; @@ -902,6 +904,7 @@ type WriteCommandParameters = { }; const READ_COMMANDS = { + GET_CORPAY_BANK_ACCOUNT_FIELDS: 'GetCorpayBankAccountFields', CONNECT_POLICY_TO_QUICKBOOKS_ONLINE: 'ConnectPolicyToQuickbooksOnline', CONNECT_POLICY_TO_XERO: 'ConnectPolicyToXero', SYNC_POLICY_TO_QUICKBOOKS_ONLINE: 'SyncPolicyToQuickbooksOnline', @@ -983,6 +986,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_PLAID_BANK_ACCOUNT_SELECTOR]: Parameters.OpenPlaidBankAccountSelectorParams; [READ_COMMANDS.GET_OLDER_ACTIONS]: Parameters.GetOlderActionsParams; [READ_COMMANDS.GET_NEWER_ACTIONS]: Parameters.GetNewerActionsParams; + [READ_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS]: Parameters.GetCorpayBankAccountFieldsParams; [READ_COMMANDS.EXPAND_URL_PREVIEW]: Parameters.ExpandURLPreviewParams; [READ_COMMANDS.GET_REPORT_PRIVATE_NOTE]: Parameters.GetReportPrivateNoteParams; [READ_COMMANDS.OPEN_ROOM_MEMBERS_PAGE]: Parameters.OpenRoomMembersPageParams; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index d76c9325cc0e..7009379e15de 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -50,6 +50,9 @@ function validateAmount(amount: string, decimals: number, amountMaxLength: numbe ? `^${shouldAllowNegative ? '-?' : ''}\\d{1,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 : `^${shouldAllowNegative ? '-?' : ''}\\d{1,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point const decimalNumberRegex = new RegExp(regexString, 'i'); + if (shouldAllowNegative) { + return amount === '' || amount === '-' || decimalNumberRegex.test(amount); + } return amount === '' || decimalNumberRegex.test(amount); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a482abeb76d9..569dca63bab7 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1052,8 +1052,8 @@ function isSettled(reportOrID: OnyxInputOrEntry | SearchReport | string /** * Whether the current user is the submitter of the report */ -function isCurrentUserSubmitter(reportID: string): boolean { - if (!allReports) { +function isCurrentUserSubmitter(reportID: string | undefined): boolean { + if (!allReports || !reportID) { return false; } const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; @@ -1455,7 +1455,7 @@ function getMostRecentlyVisitedReport(reports: Array>, reportM const shouldKeep = !isChatThread(report) || !isHiddenForCurrentUser(report); return shouldKeep && !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime); }); - return lodashMaxBy(filteredReports, (a) => new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? '').valueOf()); + return lodashMaxBy(filteredReports, (a) => [reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? '', a?.lastReadTime ?? '']); } function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = false, policyID?: string, excludeReportID?: string): OnyxEntry { @@ -6790,7 +6790,10 @@ function getInvoiceChatByParticipants(receiverID: string | number, receiverType: /** * Attempts to find a policy expense report in onyx that is owned by ownerAccountID in a given policy */ -function getPolicyExpenseChat(ownerAccountID: number, policyID: string): OnyxEntry { +function getPolicyExpenseChat(ownerAccountID: number, policyID: string | undefined): OnyxEntry { + if (!policyID) { + return; + } return Object.values(allReports ?? {}).find((report: OnyxEntry) => { // If the report has been deleted, then skip it if (!report) { diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 47b534d32cad..941ac7f59797 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -298,7 +298,7 @@ function peg$parse(input, options) { const keywordFilter = buildFilter( "eq", "keyword", - keywords.map((filter) => filter.right).flat() + keywords.map((filter) => filter.right.replace(/^(['"])(.*)\1$/, '$2')).flat() ); if (keywordFilter.right.length > 0) { nonKeywords.push(keywordFilter); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 68b3ce60963a..849008e10b76 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -66,7 +66,7 @@ function getTransactionItemCommonFormattedProperties( const formattedTotal = TransactionUtils.getAmount(transactionItem, isExpenseReport); const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created; const merchant = TransactionUtils.getMerchant(transactionItem); - const formattedMerchant = merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || merchant === CONST.TRANSACTION.DEFAULT_MERCHANT ? '' : merchant; + const formattedMerchant = merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT ? '' : merchant; return { formattedFrom, @@ -106,7 +106,7 @@ function getShouldShowMerchant(data: OnyxTypes.SearchResults['data']): boolean { if (isTransactionEntry(key)) { const item = data[key]; const merchant = item.modifiedMerchant ? item.modifiedMerchant : item.merchant ?? ''; - return merchant !== '' && merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + return merchant !== '' && merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; } return false; }); @@ -374,7 +374,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx ...reportItem, action: getAction(data, key), keyForList: reportItem.reportID, - from: data.personalDetailsList?.[reportItem.accountID ?? -1], + from: data.personalDetailsList?.[reportItem.accountID ?? CONST.DEFAULT_NUMBER_ID], to: reportItem.managerID ? data.personalDetailsList?.[reportItem.managerID] : emptyPersonalDetails, transactions, reportName: isIOUReport ? getIOUReportName(data, reportItem) : reportItem.reportName, diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 19245ba715f1..09a1f0b4f8fd 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -20,7 +20,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {PersonalBankAccountForm} from '@src/types/form'; -import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; +import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, ReimbursementAccountForm, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; import type PlaidBankAccount from '@src/types/onyx/PlaidBankAccount'; import type {BankAccountStep, ReimbursementAccountStep, ReimbursementAccountSubStep} from '@src/types/onyx/ReimbursementAccount'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -334,7 +334,7 @@ function validateBankAccount(bankAccountID: number, validateCode: string, policy key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, value: { isLoading: false, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('bankAccount.error.validationAmounts'), + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, ], @@ -344,8 +344,6 @@ function validateBankAccount(bankAccountID: number, validateCode: string, policy } function getCorpayBankAccountFields(country: string, currency: string) { - // TODO - Use parameters when API is ready - // eslint-disable-next-line @typescript-eslint/no-unused-vars const parameters = { countryISO: country, currency, @@ -353,157 +351,97 @@ function getCorpayBankAccountFields(country: string, currency: string) { isBusinessBankAccount: true, }; - // return API.read(READ_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS, parameters); - return { - bankCountry: 'AU', - bankCurrency: 'AUD', - classification: 'Business', - destinationCountry: 'AU', - formFields: [ + const onyxData: OnyxData = { + optimisticData: [ { - errorMessage: 'Swift must be less than 12 characters', - id: 'swiftBicCode', - isRequired: false, - isRequiredInValueSet: true, - label: 'Swift Code', - regEx: '^.{0,12}$', - validationRules: [ - { - errorMessage: 'Swift must be less than 12 characters', - regEx: '^.{0,12}$', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CORPAY_FIELDS, + value: { + isLoading: true, + isSuccess: false, + }, }, + ], + successData: [ { - errorMessage: 'Beneficiary Bank Name must be less than 250 characters', - id: 'bankName', - isRequired: true, - isRequiredInValueSet: true, - label: 'Bank Name', - regEx: '^.{0,250}$', - validationRules: [ - { - errorMessage: 'Beneficiary Bank Name must be less than 250 characters', - regEx: '^.{0,250}$', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CORPAY_FIELDS, + value: { + isLoading: false, + isSuccess: true, + }, }, + ], + failureData: [ { - errorMessage: 'City must be less than 100 characters', - id: 'bankCity', - isRequired: true, - isRequiredInValueSet: true, - label: 'Bank City', - regEx: '^.{0,100}$', - validationRules: [ - { - errorMessage: 'City must be less than 100 characters', - regEx: '^.{0,100}$', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CORPAY_FIELDS, + value: { + isLoading: false, + isSuccess: false, + }, }, + ], + }; + + return API.read(READ_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS, parameters, onyxData); +} + +function createCorpayBankAccount(fields: ReimbursementAccountForm) { + const parameters = { + type: 1, + isSavings: false, + isWithdrawal: true, + inputs: JSON.stringify(fields), + }; + + const onyxData: OnyxData = { + optimisticData: [ { - errorMessage: 'Bank Address Line 1 must be less than 1000 characters', - id: 'bankAddressLine1', - isRequired: true, - isRequiredInValueSet: true, - label: 'Bank Address', - regEx: '^.{0,1000}$', - validationRules: [ - { - errorMessage: 'Bank Address Line 1 must be less than 1000 characters', - regEx: '^.{0,1000}$', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: true, + isCreateCorpayBankAccount: true, + }, }, + ], + successData: [ { - detailedRule: [ - { - isRequired: true, - value: [ - { - errorMessage: 'Beneficiary Account Number is invalid. Value should be 1 to 50 characters long.', - regEx: '^.{1,50}$', - ruleDescription: '1 to 50 characters', - }, - ], - }, - ], - errorMessage: 'Beneficiary Account Number is invalid. Value should be 1 to 50 characters long.', - id: 'accountNumber', - isRequired: true, - isRequiredInValueSet: true, - label: 'Account Number (iACH)', - regEx: '^.{1,50}$', - validationRules: [ - { - errorMessage: 'Beneficiary Account Number is invalid. Value should be 1 to 50 characters long.', - regEx: '^.{1,50}$', - ruleDescription: '1 to 50 characters', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: false, + isCreateCorpayBankAccount: false, + isSuccess: true, + }, }, + ], + failureData: [ { - detailedRule: [ - { - isRequired: true, - value: [ - { - errorMessage: 'BSB Number is invalid. Value should be exactly 6 digits long.', - regEx: '^[0-9]{6}$', - ruleDescription: 'Exactly 6 digits', - }, - ], - }, - ], - errorMessage: 'BSB Number is invalid. Value should be exactly 6 digits long.', - id: 'routingCode', - isRequired: true, - isRequiredInValueSet: true, - label: 'BSB Number', - regEx: '^[0-9]{6}$', - validationRules: [ - { - errorMessage: 'BSB Number is invalid. Value should be exactly 6 digits long.', - regEx: '^[0-9]{6}$', - ruleDescription: 'Exactly 6 digits', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: false, + isCreateCorpayBankAccount: false, + isSuccess: false, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, }, ], - paymentMethods: ['E'], - preferredMethod: 'E', }; + + return API.write(WRITE_COMMANDS.BANK_ACCOUNT_CREATE_CORPAY, parameters, onyxData); } function clearReimbursementAccount() { Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, null); } +function clearReimbursementAccountBankCreation() { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isCreateCorpayBankAccount: null, isSuccess: null, isLoading: null}); +} + /** * Function to display and fetch data for Reimbursement Account step * @param stepToOpen - current step to open @@ -629,7 +567,7 @@ function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoD const parameters: VerifyIdentityForBankAccountParams = { bankAccountID, onfidoData: JSON.stringify(onfidoData), - policyID: policyID ?? '-1', + policyID, }; API.write(WRITE_COMMANDS.VERIFY_IDENTITY_FOR_BANK_ACCOUNT, parameters, getVBBADataForOnyx()); @@ -712,6 +650,7 @@ export { openPlaidView, connectBankAccountManually, connectBankAccountWithPlaid, + createCorpayBankAccount, deletePaymentBankAccount, handlePlaidError, setPersonalBankAccountContinueKYCOnSuccess, @@ -730,6 +669,7 @@ export { clearPersonalBankAccountSetupType, validatePlaidSelection, getCorpayBankAccountFields, + clearReimbursementAccountBankCreation, }; export type {BusinessAddress, PersonalAddress}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index f9e5a0a33679..c3b1b718e9a7 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7768,6 +7768,7 @@ function cancelPayment(expenseReport: OnyxEntry, chatReport: O key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, value: { ...expenseReport, + lastVisibleActionCreated: optimisticReportAction?.created, lastMessageText: ReportActionsUtils.getReportActionText(optimisticReportAction), lastMessageHtml: ReportActionsUtils.getReportActionHtml(optimisticReportAction), stateNum, @@ -7871,6 +7872,8 @@ function cancelPayment(expenseReport: OnyxEntry, chatReport: O }, {optimisticData, successData, failureData}, ); + Navigation.dismissModal(); + Report.notifyNewAction(expenseReport.reportID, userAccountID); } /** @@ -7930,6 +7933,7 @@ function payMoneyRequest(paymentType: PaymentMethodType, chatReport: OnyxTypes.R playSound(SOUNDS.SUCCESS); API.write(apiCommand, params, {optimisticData, successData, failureData}); + Report.notifyNewAction(iouReport?.reportID ?? '', userAccountID); } function payInvoice(paymentMethodType: PaymentMethodType, chatReport: OnyxTypes.Report, invoiceReport: OnyxEntry, payAsBusiness = false) { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 0bd5d6294899..cf2143c60629 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1202,9 +1202,9 @@ function clearAvatarErrors(policyID: string) { * Optimistically update the general settings. Set the general settings as pending until the response succeeds. * If the response fails set a general error message. Clear the error message when updating. */ -function updateGeneralSettings(policyID: string, name: string, currencyValue?: string) { +function updateGeneralSettings(name: string, currencyValue?: string, policyID?: string) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - if (!policy) { + if (!policy || !policyID) { return; } diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index bc19ff12aea1..204e27e1266e 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -1,19 +1,44 @@ -import type {OnyxUpdate} from 'react-native-onyx'; +import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import CONFIG from '@src/CONFIG'; +import ONYXKEYS from '@src/ONYXKEYS'; // In this file we manage a queue of Onyx updates while the SequentialQueue is processing. There are functions to get the updates and clear the queue after saving the updates in Onyx. let queuedOnyxUpdates: OnyxUpdate[] = []; +let currentAccountID: number | undefined; + +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (session) => { + currentAccountID = session?.accountID; + }, +}); /** * @param updates Onyx updates to queue for later */ function queueOnyxUpdates(updates: OnyxUpdate[]): Promise { queuedOnyxUpdates = queuedOnyxUpdates.concat(updates); + return Promise.resolve(); } function flushQueue(): Promise { + if (!currentAccountID && !CONFIG.IS_TEST_ENV) { + const preservedKeys: OnyxKey[] = [ + ONYXKEYS.NVP_TRY_FOCUS_MODE, + ONYXKEYS.PREFERRED_THEME, + ONYXKEYS.NVP_PREFERRED_LOCALE, + ONYXKEYS.SESSION, + ONYXKEYS.IS_LOADING_APP, + ONYXKEYS.CREDENTIALS, + ONYXKEYS.IS_SIDEBAR_LOADED, + ]; + + queuedOnyxUpdates = queuedOnyxUpdates.filter((update) => preservedKeys.includes(update.key as OnyxKey)); + } + return Onyx.update(queuedOnyxUpdates).then(() => { queuedOnyxUpdates = []; }); diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 462d291acf84..49c294946d09 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -834,7 +834,6 @@ function openReport( hasLoadingOlderReportActionsError: false, isLoadingNewerReportActions: false, hasLoadingNewerReportActionsError: false, - lastVisitTime: DateUtils.getDBTime(), }, }, ]; @@ -3673,6 +3672,20 @@ function prepareOnboardingOptimisticData( }; }); + // Sign-off welcome message + const welcomeSignOffComment = ReportUtils.buildOptimisticAddCommentReportAction( + Localize.translateLocal('onboarding.welcomeSignOffTitle'), + undefined, + actorAccountID, + tasksData.length + 3, + ); + const welcomeSignOffCommentAction: OptimisticAddCommentReportAction = welcomeSignOffComment.reportAction; + const welcomeSignOffMessage = { + reportID: targetChatReportID, + reportActionID: welcomeSignOffCommentAction.reportActionID, + reportComment: welcomeSignOffComment.commentText, + }; + const tasksForParameters = tasksData.map(({task, currentTask, taskCreatedAction, taskReportAction, taskDescription, completedTaskReportAction}) => ({ type: 'task', task: task.type, @@ -3828,8 +3841,7 @@ function prepareOnboardingOptimisticData( }, []); const optimisticData: OnyxUpdate[] = [...tasksForOptimisticData]; - const lastVisibleActionCreated = - tasksData.at(-1)?.completedTaskReportAction?.created ?? tasksData.at(-1)?.taskReportAction.reportAction.created ?? videoCommentAction?.created ?? textCommentAction.created; + const lastVisibleActionCreated = welcomeSignOffCommentAction.created; optimisticData.push( { @@ -4020,7 +4032,33 @@ function prepareOnboardingOptimisticData( }); } - guidedSetupData.push(...tasksForParameters); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [welcomeSignOffCommentAction.reportActionID]: welcomeSignOffCommentAction as ReportAction, + }, + }); + + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [welcomeSignOffCommentAction.reportActionID]: {pendingAction: null}, + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [welcomeSignOffCommentAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), + } as ReportAction, + }, + }); + + guidedSetupData.push(...tasksForParameters, {type: 'message', ...welcomeSignOffMessage}); return {optimisticData, successData, failureData, guidedSetupData, actorAccountID}; } diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx index 7c5d853428c5..190f40181ab4 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx @@ -1,17 +1,21 @@ import type {ComponentType} from 'react'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {useOnyx} from 'react-native-onyx'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; +import AccountHolderDetails from './substeps/AccountHolderDetails'; import BankAccountDetails from './substeps/BankAccountDetails'; import Confirmation from './substeps/Confirmation'; import UploadStatement from './substeps/UploadStatement'; -import type {BankInfoSubStepProps, CorpayFormField} from './types'; +import type {BankInfoSubStepProps} from './types'; + +const {COUNTRY} = INPUT_IDS.ADDITIONAL_DATA; type BankInfoProps = { /** Handles back button press */ @@ -21,25 +25,28 @@ type BankInfoProps = { onSubmit: () => void; }; -const {COUNTRY} = INPUT_IDS.ADDITIONAL_DATA; - -const bodyContent: Array> = [BankAccountDetails, UploadStatement, Confirmation]; - function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { const {translate} = useLocalize(); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const [corpayFields, setCorpayFields] = useState([]); - const country = reimbursementAccountDraft?.[COUNTRY] ?? ''; - const policyID = reimbursementAccount?.achData?.policyID ?? '-1'; + const [corpayFields] = useOnyx(ONYXKEYS.CORPAY_FIELDS); + const policyID = reimbursementAccount?.achData?.policyID; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const currency = policy?.outputCurrency ?? ''; + const country = reimbursementAccountDraft?.[COUNTRY] ?? reimbursementAccountDraft?.[COUNTRY] ?? ''; const submit = () => { onSubmit(); }; + useEffect(() => { + BankAccounts.getCorpayBankAccountFields(country, currency); + }, [country, currency]); + + const bodyContent: Array> = + currency !== CONST.CURRENCY.AUD ? [BankAccountDetails, AccountHolderDetails, Confirmation] : [BankAccountDetails, AccountHolderDetails, UploadStatement, Confirmation]; + const { componentToRender: SubStep, isEditing, @@ -48,15 +55,8 @@ function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { prevScreen, moveTo, goToTheLastStep, - resetScreenIndex, } = useSubStep({bodyContent, startFrom: 0, onFinished: submit}); - // Temporary solution to get the fields for the corpay bank account fields - useEffect(() => { - const response = BankAccounts.getCorpayBankAccountFields(country, currency); - setCorpayFields((response?.formFields as CorpayFormField[]) ?? []); - }, [country, currency]); - const handleBackButtonPress = () => { if (isEditing) { goToTheLastStep(); @@ -65,26 +65,14 @@ function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { if (screenIndex === 0) { onBackButtonPress(); - } else if (currency === CONST.CURRENCY.AUD) { - prevScreen(); } else { - resetScreenIndex(); + prevScreen(); } }; - const handleNextScreen = useCallback(() => { - if (screenIndex === 2) { - nextScreen(); - return; - } - - if (currency !== CONST.CURRENCY.AUD) { - goToTheLastStep(); - return; - } - - nextScreen(); - }, [currency, goToTheLastStep, nextScreen, screenIndex]); + if (corpayFields?.isLoading !== undefined && !corpayFields?.isLoading && corpayFields?.isSuccess !== undefined && !corpayFields?.isSuccess) { + return ; + } return ( diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx new file mode 100644 index 000000000000..268356dee063 --- /dev/null +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx @@ -0,0 +1,134 @@ +import React, {useCallback, useMemo} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import PushRowWithModal from '@components/PushRowWithModal'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {BankInfoSubStepProps} from '@pages/ReimbursementAccount/NonUSD/BankInfo/types'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccountForm} from '@src/types/form/ReimbursementAccountForm'; +import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; + +const {ACCOUNT_HOLDER_COUNTRY} = INPUT_IDS.ADDITIONAL_DATA.CORPAY; + +function AccountHolderDetails({onNext, isEditing, corpayFields}: BankInfoSubStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const accountHolderDetailsFields = useMemo(() => { + return corpayFields?.formFields?.filter((field) => field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)); + }, [corpayFields]); + const fieldIds = accountHolderDetailsFields?.map((field) => field.id); + + const subStepKeys = accountHolderDetailsFields?.reduce((acc, field) => { + acc[field.id as keyof ReimbursementAccountForm] = field.id as keyof ReimbursementAccountForm; + return acc; + }, {} as Record); + + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); + const defaultValues = useMemo(() => getSubstepValues(subStepKeys ?? {}, reimbursementAccountDraft, reimbursementAccount), [subStepKeys, reimbursementAccount, reimbursementAccountDraft]); + + const handleSubmit = useReimbursementAccountStepFormSubmit({ + fieldIds: fieldIds as Array>, + onNext, + shouldSaveDraft: isEditing, + }); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + + accountHolderDetailsFields?.forEach((field) => { + const fieldID = field.id as keyof FormOnyxValues; + + if (field.isRequired && !values[fieldID]) { + errors[fieldID] = translate('common.error.fieldRequired'); + } + + field.validationRules.forEach((rule) => { + if (!rule.regEx) { + return; + } + + if (new RegExp(rule.regEx).test(values[fieldID] ? String(values[fieldID]) : '')) { + return; + } + + errors[fieldID] = rule.errorMessage; + }); + }); + + return errors; + }, + [accountHolderDetailsFields, translate], + ); + + const inputs = useMemo(() => { + return accountHolderDetailsFields?.map((field) => { + if (field.id === ACCOUNT_HOLDER_COUNTRY) { + return ( + + + + ); + } + + return ( + + + + ); + }); + }, [accountHolderDetailsFields, styles.mb6, styles.mhn5, defaultValues, isEditing, translate]); + + return ( + + + {translate('bankInfoStep.whatAreYour')} + {inputs} + + + ); +} + +AccountHolderDetails.displayName = 'AccountHolderDetails'; + +export default AccountHolderDetails; diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx index b3482a516c1f..b7492790cfb2 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; +import {ActivityIndicator, View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; @@ -7,6 +7,7 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import type {BankInfoSubStepProps} from '@pages/ReimbursementAccount/NonUSD/BankInfo/types'; import CONST from '@src/CONST'; @@ -15,14 +16,19 @@ import ONYXKEYS from '@src/ONYXKEYS'; function BankAccountDetails({onNext, isEditing, corpayFields}: BankInfoSubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const theme = useTheme(); - const fieldIds = corpayFields.map((field) => field.id); + const bankAccountDetailsFields = useMemo(() => { + return corpayFields?.formFields?.filter((field) => !field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)); + }, [corpayFields]); + + const fieldIds = bankAccountDetailsFields?.map((field) => field.id); const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; - corpayFields.forEach((field) => { + corpayFields?.formFields?.forEach((field) => { const fieldID = field.id as keyof FormOnyxValues; if (field.isRequired && !values[fieldID]) { @@ -30,7 +36,7 @@ function BankAccountDetails({onNext, isEditing, corpayFields}: BankInfoSubStepPr } field.validationRules.forEach((rule) => { - if (rule.regEx) { + if (!rule.regEx) { return; } @@ -54,10 +60,10 @@ function BankAccountDetails({onNext, isEditing, corpayFields}: BankInfoSubStepPr }); const inputs = useMemo(() => { - return corpayFields.map((field) => { + return bankAccountDetailsFields?.map((field) => { return ( ); }); - }, [corpayFields, styles.flex2, styles.mb6, isEditing]); + }, [bankAccountDetailsFields, styles.mb6, isEditing]); return ( {translate('bankInfoStep.whatAreYour')} {inputs} + {!inputs && ( + + )} ); diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx index 8abe5e41aaaf..f10ee293a924 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx @@ -1,19 +1,20 @@ -import React, {useMemo} from 'react'; +import React, {useEffect, useMemo} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import Button from '@components/Button'; +import FormProvider from '@components/Form/FormProvider'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import SafeAreaConsumer from '@components/SafeAreaConsumer'; -import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {BankInfoSubStepProps} from '@pages/ReimbursementAccount/NonUSD/BankInfo/types'; import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import * as BankAccounts from '@userActions/BankAccounts'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReimbursementAccountForm} from '@src/types/form/ReimbursementAccountForm'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; +const {ACCOUNT_HOLDER_COUNTRY} = INPUT_IDS.ADDITIONAL_DATA.CORPAY; function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -22,8 +23,8 @@ function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) { const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); const inputKeys = useMemo(() => { const keys: Record = {}; - corpayFields.forEach((field) => { - keys[field.id] = field.id; + corpayFields?.formFields?.forEach((field) => { + keys[field.id] = field.id as keyof ReimbursementAccountForm; }); return keys; }, [corpayFields]); @@ -32,14 +33,24 @@ function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) { const items = useMemo( () => ( <> - {corpayFields.map((field) => { + {corpayFields?.formFields?.map((field) => { + let title = values[field.id as keyof typeof values] ? String(values[field.id as keyof typeof values]) : ''; + + if (field.id === ACCOUNT_HOLDER_COUNTRY) { + title = CONST.ALL_COUNTRIES[title as keyof typeof CONST.ALL_COUNTRIES]; + } + return ( { - onMove(0); + if (!field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)) { + onMove(0); + } else { + onMove(1); + } }} key={field.id} /> @@ -50,7 +61,7 @@ function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) { description={translate('bankInfoStep.bankStatement')} title={reimbursementAccountDraft[INPUT_IDS.ADDITIONAL_DATA.CORPAY.BANK_STATEMENT].map((file) => file.name).join(', ')} shouldShowRightIcon - onPress={() => onMove(1)} + onPress={() => onMove(2)} /> )} @@ -58,28 +69,40 @@ function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) { [corpayFields, onMove, reimbursementAccountDraft, translate, values], ); + const handleSubmit = () => { + const {formFields, isLoading, isSuccess, ...corpayData} = corpayFields ?? {}; + + BankAccounts.createCorpayBankAccount({...reimbursementAccountDraft, ...corpayData} as ReimbursementAccountForm); + }; + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (reimbursementAccount?.errors || reimbursementAccount?.isLoading || !reimbursementAccount?.isSuccess) { + return; + } + + if (reimbursementAccount?.isSuccess) { + onNext(); + BankAccounts.clearReimbursementAccountBankCreation(); + } + + return () => BankAccounts.clearReimbursementAccountBankCreation(); + }, [onNext, reimbursementAccount?.errors, reimbursementAccount?.isCreateCorpayBankAccount, reimbursementAccount?.isLoading, reimbursementAccount?.isSuccess]); + return ( - - {({safeAreaPaddingBottomStyle}) => ( - - {translate('bankInfoStep.letsDoubleCheck')} - {translate('bankInfoStep.thisBankAccount')} - {items} - -