diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 09113bdcc548..75c0b8497bdd 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -194,7 +194,7 @@ jobs: with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-west-1 + aws-region: us-west-2 - name: Schedule AWS Device Farm test run on main branch uses: realm/aws-devicefarm/test-application@7b9a91236c456c97e28d384c9e476035d5ea686b diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 4c5dfdb14627..6aeecb3b4e05 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -10,7 +10,7 @@ on: env: SHOULD_DEPLOY_PRODUCTION: ${{ github.event_name == 'release' }} - DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.0.1.app/Contents/Developer concurrency: group: ${{ github.workflow }}-${{ github.event_name }} diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 5d860b41b786..8b18b8aa5d53 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -11,7 +11,7 @@ on: branches: ['*ci-test/**'] env: - DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.0.1.app/Contents/Developer jobs: validateActor: @@ -159,7 +159,7 @@ jobs: uses: ./.github/actions/composite/setupNode - name: Setup XCode - run: sudo xcode-select -switch /Applications/Xcode_14.2.app + run: sudo xcode-select -switch /Applications/Xcode_15.0.1.app - name: Setup Ruby uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011 diff --git a/android/app/build.gradle b/android/app/build.gradle index 341c9ff046a1..4dd8d1766953 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,8 +96,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001041803 - versionName "1.4.18-3" + versionCode 1001042002 + versionName "1.4.20-2" } flavorDimensions "default" diff --git a/assets/images/product-illustrations/telescope.svg b/assets/images/product-illustrations/telescope.svg new file mode 100644 index 000000000000..95617c801789 --- /dev/null +++ b/assets/images/product-illustrations/telescope.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index 4246b23982ec..0887e90dcc8b 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -113,8 +113,8 @@ platforms: icon: /assets/images/hand-card.svg description: Explore how the Expensify Card combines convenience and security to enhance everyday business transactions. Discover how to apply for, oversee, and maximize your card perks here. - - href: get-paid-back - title: Get Paid Back + - href: payments + title: Payments icon: /assets/images/money-into-wallet.svg description: Whether you submit an expense report or an invoice, find out here how to ensure a smooth and timely payback process every time. diff --git a/docs/articles/new-expensify/get-paid-back/Distance-Requests.md b/docs/articles/new-expensify/payments/Distance-Requests.md similarity index 100% rename from docs/articles/new-expensify/get-paid-back/Distance-Requests.md rename to docs/articles/new-expensify/payments/Distance-Requests.md diff --git a/docs/articles/new-expensify/get-paid-back/Referral-Program.md b/docs/articles/new-expensify/payments/Referral-Program.md similarity index 100% rename from docs/articles/new-expensify/get-paid-back/Referral-Program.md rename to docs/articles/new-expensify/payments/Referral-Program.md diff --git a/docs/articles/new-expensify/get-paid-back/Request-Money.md b/docs/articles/new-expensify/payments/Request-Money.md similarity index 100% rename from docs/articles/new-expensify/get-paid-back/Request-Money.md rename to docs/articles/new-expensify/payments/Request-Money.md diff --git a/docs/new-expensify/hubs/get-paid-back/index.html b/docs/new-expensify/hubs/payments/index.html similarity index 69% rename from docs/new-expensify/hubs/get-paid-back/index.html rename to docs/new-expensify/hubs/payments/index.html index 1f84c1510b92..d22f7e375f2e 100644 --- a/docs/new-expensify/hubs/get-paid-back/index.html +++ b/docs/new-expensify/hubs/payments/index.html @@ -1,6 +1,6 @@ --- layout: default -title: Get Paid Back +title: Payments --- {% include hub.html %} \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 51de98b49720..8d60003e1355 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.18 + 1.4.20 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.18.3 + 1.4.20.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index c68c221f7add..740a07ea24ac 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.18 + 1.4.20 CFBundleSignature ???? CFBundleVersion - 1.4.18.3 + 1.4.20.2 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 318d62f0a944..77c390c46416 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -802,35 +802,10 @@ PODS: - React-Core - RNReactNativeHapticFeedback (1.14.0): - React-Core - - RNReanimated (3.5.4): - - DoubleConversion - - FBLazyVector - - glog - - hermes-engine - - RCT-Folly - - RCTRequired - - RCTTypeSafety - - React-callinvoker + - RNReanimated (3.6.1): + - RCT-Folly (= 2021.07.22.00) - React-Core - - React-Core/DevSupport - - React-Core/RCTWebSocket - - React-CoreModules - - React-cxxreact - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-RCTActionSheet - - React-RCTAnimation - - React-RCTAppDelegate - - React-RCTBlob - - React-RCTImage - - React-RCTLinking - - React-RCTNetwork - - React-RCTSettings - - React-RCTText - ReactCommon/turbomodule/core - - Yoga - RNScreens (3.21.0): - React-Core - React-RCTImage @@ -1333,7 +1308,7 @@ SPEC CHECKSUMS: rnmapbox-maps: 6f638ec002aa6e906a6f766d69cd45f968d98e64 RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c - RNReanimated: ab2e96c6d5591c3dfbb38a464f54c8d17fb34a87 + RNReanimated: fdbaa9c964bbab7fac50c90862b6cc5f041679b9 RNScreens: d037903436160a4b039d32606668350d2a808806 RNSVG: d00c8f91c3cbf6d476451313a18f04d220d4f396 SDWebImage: 750adf017a315a280c60fde706ab1e552a3ae4e9 diff --git a/package-lock.json b/package-lock.json index bcf1b3650227..e00ae28b8113 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.18-3", + "version": "1.4.20-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.18-3", + "version": "1.4.20-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -103,7 +103,7 @@ "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", - "react-native-reanimated": "3.5.4", + "react-native-reanimated": "^3.6.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.4.1", "react-native-screens": "3.21.0", @@ -47677,9 +47677,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.5.4.tgz", - "integrity": "sha512-8we9LLDO1o4Oj9/DICeEJ2K1tjfqkJagqQUglxeUAkol/HcEJ6PGxIrpBcNryLqCDYEcu6FZWld/FzizBIw6bg==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.6.1.tgz", + "integrity": "sha512-F4vG9Yf9PKmE3GaWtVGUpzj3SM6YY2cx1yRHCwiMd1uY7W0gU017LfcVUorboJnj0y5QZqEriEK1Usq2Y8YZqg==", "dependencies": { "@babel/plugin-transform-object-assign": "^7.16.7", "@babel/preset-typescript": "^7.16.7", @@ -90489,9 +90489,9 @@ "requires": {} }, "react-native-reanimated": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.5.4.tgz", - "integrity": "sha512-8we9LLDO1o4Oj9/DICeEJ2K1tjfqkJagqQUglxeUAkol/HcEJ6PGxIrpBcNryLqCDYEcu6FZWld/FzizBIw6bg==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.6.1.tgz", + "integrity": "sha512-F4vG9Yf9PKmE3GaWtVGUpzj3SM6YY2cx1yRHCwiMd1uY7W0gU017LfcVUorboJnj0y5QZqEriEK1Usq2Y8YZqg==", "requires": { "@babel/plugin-transform-object-assign": "^7.16.7", "@babel/preset-typescript": "^7.16.7", diff --git a/package.json b/package.json index 3f2029118438..28dc12938d60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.18-3", + "version": "1.4.20-2", "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.", @@ -151,7 +151,7 @@ "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", - "react-native-reanimated": "3.5.4", + "react-native-reanimated": "^3.6.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.4.1", "react-native-screens": "3.21.0", diff --git a/src/CONST.ts b/src/CONST.ts index 34720adf8a21..abba27b0c33b 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -594,6 +594,7 @@ const CONST = { JOIN_ROOM: 'JOINROOM', }, }, + THREAD_DISABLED: ['CREATED'], }, ARCHIVE_REASON: { DEFAULT: 'default', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 49f0337798ee..db17378684d6 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -372,6 +372,12 @@ const ROUTES = { getRoute: (iouType: ValueOf, transactionID: string, reportID: string, pageIndex = '', backTo = '') => getUrlWithBackToParam(`create/${iouType}/waypoint/${transactionID}/${reportID}/${pageIndex}`, backTo), }, + // This URL is used as a redirect to one of the create tabs below. This is so that we can message users with a link + // straight to those flows without needing to have optimistic transaction and report IDs. + MONEY_REQUEST_START: { + route: 'start/:iouType/:iouRequestType', + getRoute: (iouType: ValueOf, iouRequestType: ValueOf) => `start/${iouType}/${iouRequestType}` as const, + }, MONEY_REQUEST_CREATE_TAB_DISTANCE: { route: 'create/:iouType/start/:transactionID/:reportID/distance', getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/distance` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2cd263237866..c1d2059cd3b0 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -118,6 +118,7 @@ const SCREENS = { DISTANCE_TAB: 'distance', CREATE: 'Money_Request_Create', STEP_CONFIRMATION: 'Money_Request_Step_Confirmation', + START: 'Money_Request_Start', STEP_AMOUNT: 'Money_Request_Step_Amount', STEP_CATEGORY: 'Money_Request_Step_Category', STEP_CURRENCY: 'Money_Request_Step_Currency', diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index c01843c7bcb3..b0060afdb813 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -146,6 +146,7 @@ function AttachmentView({ onLoadComplete={() => !loadComplete && setLoadComplete(true)} errorLabelStyles={isUsedInAttachmentModal ? [styles.textLabel, styles.textLarge] : [styles.cursorAuto]} style={isUsedInAttachmentModal ? styles.imageModalPDF : styles.flex1} + isUsedInCarousel={isUsedInCarousel} /> ); diff --git a/src/components/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.tsx similarity index 56% rename from src/components/BlockingViews/BlockingView.js rename to src/components/BlockingViews/BlockingView.tsx index 5c0a8a9711e7..3a038c58d886 100644 --- a/src/components/BlockingViews/BlockingView.js +++ b/src/components/BlockingViews/BlockingView.tsx @@ -1,60 +1,60 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import {View} from 'react-native'; +import {ImageSourcePropType, View} from 'react-native'; +import {SvgProps} from 'react-native-svg'; import AutoEmailLink from '@components/AutoEmailLink'; import Icon from '@components/Icon'; -import sourcePropTypes from '@components/Image/sourcePropTypes'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; +import {TranslationPaths} from '@src/languages/types'; -const propTypes = { +type BlockingViewProps = { /** Expensicon for the page */ - icon: sourcePropTypes.isRequired, + icon: React.FC | ImageSourcePropType; /** Color for the icon (should be from theme) */ - iconColor: PropTypes.string, + iconColor?: string; /** Title message below the icon */ - title: PropTypes.string.isRequired, + title: string; /** Subtitle message below the title */ - subtitle: PropTypes.string, + subtitle?: string; /** Link message below the subtitle */ - linkKey: PropTypes.string, + linkKey?: TranslationPaths; /** Whether we should show a link to navigate elsewhere */ - shouldShowLink: PropTypes.bool, + shouldShowLink?: boolean; /** The custom icon width */ - iconWidth: PropTypes.number, + iconWidth?: number; /** The custom icon height */ - iconHeight: PropTypes.number, + iconHeight?: number; /** Function to call when pressing the navigation link */ - onLinkPress: PropTypes.func, + onLinkPress?: () => void; /** Whether we should embed the link with subtitle */ - shouldEmbedLinkWithSubtitle: PropTypes.bool, + shouldEmbedLinkWithSubtitle?: boolean; }; -const defaultProps = { - iconColor: null, - subtitle: '', - shouldShowLink: false, - linkKey: 'notFound.goBackHome', - iconWidth: variables.iconSizeSuperLarge, - iconHeight: variables.iconSizeSuperLarge, - onLinkPress: () => Navigation.dismissModal(), - shouldEmbedLinkWithSubtitle: false, -}; - -function BlockingView(props) { +function BlockingView({ + icon, + iconColor, + title, + subtitle = '', + linkKey = 'notFound.goBackHome', + shouldShowLink = false, + iconWidth = variables.iconSizeSuperLarge, + iconHeight = variables.iconSizeSuperLarge, + onLinkPress = () => Navigation.dismissModal(), + shouldEmbedLinkWithSubtitle = false, +}: BlockingViewProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); function renderContent() { @@ -62,14 +62,14 @@ function BlockingView(props) { <> - {props.shouldShowLink ? ( + {shouldShowLink ? ( - {translate(props.linkKey)} + {translate(linkKey)} ) : null} @@ -79,14 +79,14 @@ function BlockingView(props) { return ( - {props.title} + {title} - {props.shouldEmbedLinkWithSubtitle ? ( + {shouldEmbedLinkWithSubtitle ? ( {renderContent()} ) : ( {renderContent()} @@ -95,8 +95,6 @@ function BlockingView(props) { ); } -BlockingView.propTypes = propTypes; -BlockingView.defaultProps = defaultProps; BlockingView.displayName = 'BlockingView'; export default BlockingView; diff --git a/src/components/BlockingViews/FullPageNotFoundView.js b/src/components/BlockingViews/FullPageNotFoundView.tsx similarity index 69% rename from src/components/BlockingViews/FullPageNotFoundView.js rename to src/components/BlockingViews/FullPageNotFoundView.tsx index ce76b96c0eb0..6d7f838bf6c2 100644 --- a/src/components/BlockingViews/FullPageNotFoundView.js +++ b/src/components/BlockingViews/FullPageNotFoundView.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -7,54 +6,54 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; +import {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import BlockingView from './BlockingView'; -const propTypes = { +type FullPageNotFoundViewProps = { /** Child elements */ - children: PropTypes.node, + children?: React.ReactNode; /** If true, child components are replaced with a blocking "not found" view */ - shouldShow: PropTypes.bool, + shouldShow?: boolean; /** The key in the translations file to use for the title */ - titleKey: PropTypes.string, + titleKey?: TranslationPaths; /** The key in the translations file to use for the subtitle */ - subtitleKey: PropTypes.string, + subtitleKey?: TranslationPaths; /** Whether we should show a link to navigate elsewhere */ - shouldShowLink: PropTypes.bool, + shouldShowLink?: boolean; /** Whether we should show the back button on the header */ - shouldShowBackButton: PropTypes.bool, + shouldShowBackButton?: boolean; /** The key in the translations file to use for the go back link */ - linkKey: PropTypes.string, + linkKey?: TranslationPaths; /** Method to trigger when pressing the back button of the header */ - onBackButtonPress: PropTypes.func, + onBackButtonPress: () => void; /** Function to call when pressing the navigation link */ - onLinkPress: PropTypes.func, -}; - -const defaultProps = { - children: null, - shouldShow: false, - titleKey: 'notFound.notHere', - subtitleKey: 'notFound.pageNotFound', - linkKey: 'notFound.goBackHome', - onBackButtonPress: () => Navigation.goBack(ROUTES.HOME), - shouldShowLink: true, - shouldShowBackButton: true, - onLinkPress: () => Navigation.dismissModal(), + onLinkPress: () => void; }; // eslint-disable-next-line rulesdir/no-negated-variables -function FullPageNotFoundView({children, shouldShow, titleKey, subtitleKey, linkKey, onBackButtonPress, shouldShowLink, shouldShowBackButton, onLinkPress}) { +function FullPageNotFoundView({ + children = null, + shouldShow = false, + titleKey = 'notFound.notHere', + subtitleKey = 'notFound.pageNotFound', + linkKey = 'notFound.goBackHome', + onBackButtonPress = () => Navigation.goBack(ROUTES.HOME), + shouldShowLink = true, + shouldShowBackButton = true, + onLinkPress = () => Navigation.dismissModal(), +}: FullPageNotFoundViewProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + if (shouldShow) { return ( <> @@ -81,8 +80,6 @@ function FullPageNotFoundView({children, shouldShow, titleKey, subtitleKey, link return children; } -FullPageNotFoundView.propTypes = propTypes; -FullPageNotFoundView.defaultProps = defaultProps; FullPageNotFoundView.displayName = 'FullPageNotFoundView'; export default FullPageNotFoundView; diff --git a/src/components/BlockingViews/FullPageOfflineBlockingView.js b/src/components/BlockingViews/FullPageOfflineBlockingView.js deleted file mode 100644 index adbda21456dc..000000000000 --- a/src/components/BlockingViews/FullPageOfflineBlockingView.js +++ /dev/null @@ -1,42 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import * as Expensicons from '@components/Icon/Expensicons'; -import networkPropTypes from '@components/networkPropTypes'; -import {withNetwork} from '@components/OnyxProvider'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import useTheme from '@hooks/useTheme'; -import compose from '@libs/compose'; -import BlockingView from './BlockingView'; - -const propTypes = { - /** Child elements */ - children: PropTypes.node.isRequired, - - /** Props to fetch translation features */ - ...withLocalizePropTypes, - - /** Props to detect online status */ - network: networkPropTypes.isRequired, -}; - -function FullPageOfflineBlockingView(props) { - const theme = useTheme(); - - if (props.network.isOffline) { - return ( - - ); - } - - return props.children; -} - -FullPageOfflineBlockingView.propTypes = propTypes; -FullPageOfflineBlockingView.displayName = 'FullPageOfflineBlockingView'; - -export default compose(withLocalize, withNetwork())(FullPageOfflineBlockingView); diff --git a/src/components/BlockingViews/FullPageOfflineBlockingView.tsx b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx new file mode 100644 index 000000000000..a9ebcf969ae5 --- /dev/null +++ b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import * as Expensicons from '@components/Icon/Expensicons'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useTheme from '@hooks/useTheme'; +import ChildrenProps from '@src/types/utils/ChildrenProps'; +import BlockingView from './BlockingView'; + +function FullPageOfflineBlockingView({children}: ChildrenProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + + const theme = useTheme(); + + if (isOffline) { + return ( + + ); + } + + return children; +} + +FullPageOfflineBlockingView.displayName = 'FullPageOfflineBlockingView'; + +export default FullPageOfflineBlockingView; diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx index 715603ea362e..ac18b550501d 100644 --- a/src/components/Checkbox.tsx +++ b/src/components/Checkbox.tsx @@ -1,4 +1,4 @@ -import React, {ForwardedRef, forwardRef, KeyboardEvent as ReactKeyboardEvent} from 'react'; +import React, {type ForwardedRef, forwardRef, type MouseEventHandler, type KeyboardEvent as ReactKeyboardEvent} from 'react'; import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -29,7 +29,7 @@ type CheckboxProps = Partial & { containerStyle?: StyleProp; /** Callback that is called when mousedown is triggered. */ - onMouseDown?: () => void; + onMouseDown?: MouseEventHandler; /** The size of the checkbox container */ containerSize?: number; diff --git a/src/components/DatePicker/CalendarPicker/index.js b/src/components/DatePicker/CalendarPicker/index.js index bbdeda6ef84f..571ddc820d43 100644 --- a/src/components/DatePicker/CalendarPicker/index.js +++ b/src/components/DatePicker/CalendarPicker/index.js @@ -50,7 +50,7 @@ class CalendarPicker extends React.PureComponent { if (props.minDate >= props.maxDate) { throw new Error('Minimum date cannot be greater than the maximum date.'); } - let currentDateView = new Date(props.value); + let currentDateView = typeof props.value === 'string' ? parseISO(props.value) : new Date(props.value); if (props.maxDate < currentDateView) { currentDateView = props.maxDate; } else if (props.minDate > currentDateView) { diff --git a/src/components/FloatingActionButton.js b/src/components/FloatingActionButton.js index 791eb150f8c9..59e741001063 100644 --- a/src/components/FloatingActionButton.js +++ b/src/components/FloatingActionButton.js @@ -1,132 +1,130 @@ import PropTypes from 'prop-types'; -import React, {PureComponent} from 'react'; -import {Animated, Easing, View} from 'react-native'; -import compose from '@libs/compose'; -import Icon from './Icon'; -import * as Expensicons from './Icon/Expensicons'; +import React, {useEffect, useRef} from 'react'; +import {Platform, View} from 'react-native'; +import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Svg, {Path} from 'react-native-svg'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; import Tooltip from './Tooltip/PopoverAnchorTooltip'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import withStyleUtils, {withStyleUtilsPropTypes} from './withStyleUtils'; -import withTheme, {withThemePropTypes} from './withTheme'; -import withThemeStyles, {withThemeStylesPropTypes} from './withThemeStyles'; -const AnimatedIcon = Animated.createAnimatedComponent(Icon); -AnimatedIcon.displayName = 'AnimatedIcon'; +const AnimatedPath = Animated.createAnimatedComponent(Path); +AnimatedPath.displayName = 'AnimatedPath'; const AnimatedPressable = Animated.createAnimatedComponent(PressableWithFeedback); AnimatedPressable.displayName = 'AnimatedPressable'; +const adapter = createAnimatedPropAdapter( + (props) => { + // eslint-disable-next-line rulesdir/prefer-underscore-method + if (Object.keys(props).includes('fill')) { + // eslint-disable-next-line no-param-reassign + props.fill = {type: 0, payload: processColor(props.fill)}; + } + // eslint-disable-next-line rulesdir/prefer-underscore-method + if (Object.keys(props).includes('stroke')) { + // eslint-disable-next-line no-param-reassign + props.stroke = {type: 0, payload: processColor(props.stroke)}; + } + }, + ['fill', 'stroke'], +); +adapter.propTypes = { + fill: PropTypes.string, + stroke: PropTypes.string, +}; + const propTypes = { - // Callback to fire on request to toggle the FloatingActionButton + /* Callback to fire on request to toggle the FloatingActionButton */ onPress: PropTypes.func.isRequired, - // Current state (active or not active) of the component + /* Current state (active or not active) of the component */ isActive: PropTypes.bool.isRequired, - // Ref for the button - buttonRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - - ...withLocalizePropTypes, - ...withThemePropTypes, - ...withThemeStylesPropTypes, - ...withStyleUtilsPropTypes, -}; + /* An accessibility label for the button */ + accessibilityLabel: PropTypes.string.isRequired, -const defaultProps = { - buttonRef: () => {}, + /* An accessibility role for the button */ + role: PropTypes.string.isRequired, }; -class FloatingActionButton extends PureComponent { - constructor(props) { - super(props); - this.animatedValue = new Animated.Value(props.isActive ? 1 : 0); - } - - componentDidUpdate(prevProps) { - if (prevProps.isActive === this.props.isActive) { - return; - } - - this.animateFloatingActionButton(); - } - - /** - * Animates the floating action button - * Method is called when the isActive prop changes - */ - animateFloatingActionButton() { - const animationFinalValue = this.props.isActive ? 1 : 0; - - Animated.timing(this.animatedValue, { - toValue: animationFinalValue, +const FloatingActionButton = React.forwardRef(({onPress, isActive, accessibilityLabel, role}, ref) => { + const {success, buttonDefaultBG, textLight, textDark} = useTheme(); + const styles = useThemeStyles(); + const borderRadius = styles.floatingActionButton.borderRadius; + const {translate} = useLocalize(); + const fabPressable = useRef(null); + const sharedValue = useSharedValue(isActive ? 1 : 0); + const buttonRef = ref; + + useEffect(() => { + sharedValue.value = withTiming(isActive ? 1 : 0, { duration: 340, easing: Easing.inOut(Easing.ease), - useNativeDriver: false, - }).start(); - } - - render() { - const rotate = this.animatedValue.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '135deg'], - }); - - const backgroundColor = this.animatedValue.interpolate({ - inputRange: [0, 1], - outputRange: [this.props.theme.success, this.props.theme.buttonDefaultBG], - }); - - const fill = this.animatedValue.interpolate({ - inputRange: [0, 1], - outputRange: [this.props.theme.textLight, this.props.theme.textDark], }); - - return ( - - - { - this.fabPressable = el; - if (this.props.buttonRef) { - this.props.buttonRef.current = el; - } - }} - accessibilityLabel={this.props.accessibilityLabel} - role={this.props.role} - pressDimmingValue={1} - onPress={(e) => { - // Drop focus to avoid blue focus ring. - this.fabPressable.blur(); - this.props.onPress(e); - }} - onLongPress={() => {}} - style={[this.props.themeStyles.floatingActionButton, this.props.StyleUtils.getAnimatedFABStyle(rotate, backgroundColor)]} + }, [isActive, sharedValue]); + + const animatedStyle = useAnimatedStyle(() => { + const backgroundColor = interpolateColor(sharedValue.value, [0, 1], [success, buttonDefaultBG]); + + return { + transform: [{rotate: `${sharedValue.value * 135}deg`}], + backgroundColor, + borderRadius, + }; + }); + + const animatedProps = useAnimatedProps( + () => { + const fill = interpolateColor(sharedValue.value, [0, 1], [textLight, textDark]); + + return { + fill, + }; + }, + undefined, + Platform.OS === 'web' ? undefined : adapter, + ); + + return ( + + + { + fabPressable.current = el; + if (buttonRef) { + buttonRef.current = el; + } + }} + accessibilityLabel={accessibilityLabel} + role={role} + pressDimmingValue={1} + onPress={(e) => { + // Drop focus to avoid blue focus ring. + fabPressable.current.blur(); + onPress(e); + }} + onLongPress={() => {}} + style={[styles.floatingActionButton, animatedStyle]} + > + - - - - - ); - } -} + + + + + ); +}); FloatingActionButton.propTypes = propTypes; -FloatingActionButton.defaultProps = defaultProps; - -const FloatingActionButtonWithLocalize = withLocalize(FloatingActionButton); - -const FloatingActionButtonWithLocalizeWithRef = React.forwardRef((props, ref) => ( - -)); - -FloatingActionButtonWithLocalizeWithRef.displayName = 'FloatingActionButtonWithLocalizeWithRef'; +FloatingActionButton.displayName = 'FloatingActionButton'; -export default compose(withThemeStyles, withTheme, withStyleUtils)(FloatingActionButtonWithLocalizeWithRef); +export default FloatingActionButton; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index c02f21d7c6f2..4eac2c7a6994 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,4 +1,4 @@ -import React, {ReactElement} from 'react'; +import React, {ReactNode} from 'react'; import {StyleProp, TextStyle, View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import EnvironmentBadge from './EnvironmentBadge'; @@ -6,10 +6,10 @@ import Text from './Text'; type HeaderProps = { /** Title of the Header */ - title?: string | ReactElement; + title?: ReactNode; /** Subtitle of the header */ - subtitle?: string | ReactElement; + subtitle?: ReactNode; /** Should we show the environment badge (dev/stg)? */ shouldShowEnvironmentBadge?: boolean; diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.tsx similarity index 91% rename from src/components/HeaderWithBackButton/index.js rename to src/components/HeaderWithBackButton/index.tsx index 738afbfaeeba..9ec8bca55a95 100755 --- a/src/components/HeaderWithBackButton/index.js +++ b/src/components/HeaderWithBackButton/index.tsx @@ -19,18 +19,18 @@ import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import headerWithBackButtonPropTypes from './headerWithBackButtonPropTypes'; +import HeaderWithBackButtonProps from './types'; function HeaderWithBackButton({ - iconFill = null, + iconFill, guidesCallTaskID = '', onBackButtonPress = () => Navigation.goBack(ROUTES.HOME), onCloseButtonPress = () => Navigation.dismissModal(), onDownloadButtonPress = () => {}, onThreeDotsButtonPress = () => {}, report = null, - policy = {}, - personalDetails = {}, + policy, + personalDetails = null, shouldShowAvatarWithDisplay = false, shouldShowBackButton = true, shouldShowBorderBottom = false, @@ -41,10 +41,10 @@ function HeaderWithBackButton({ shouldShowPinButton = false, shouldShowThreeDotsButton = false, shouldDisableThreeDotsButton = false, - stepCounter = null, + stepCounter, subtitle = '', title = '', - titleColor = undefined, + titleColor, threeDotsAnchorPosition = { vertical: 0, horizontal: 0, @@ -55,14 +55,16 @@ function HeaderWithBackButton({ shouldOverlay = false, singleExecution = (func) => func, shouldNavigateToTopMostReport = false, -}) { +}: HeaderWithBackButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); + // @ts-expect-error TODO: Remove this once useKeyboardState (https://github.com/Expensify/App/issues/24941) is migrated to TypeScript. const {isKeyboardShown} = useKeyboardState(); const waitForNavigate = useWaitForNavigation(); + return ( @@ -116,17 +118,17 @@ function HeaderWithBackButton({ {shouldShowDownloadButton && ( { + onPress={(event) => { // Blur the pressable in case this button triggers a Growl notification // We do not want to overlap Growl with the Tooltip (#15271) - e.currentTarget.blur(); + (event?.currentTarget as HTMLElement)?.blur(); if (!isDownloadButtonActive) { return; } onDownloadButtonPress(); - temporarilyDisableDownloadButton(true); + temporarilyDisableDownloadButton(); }} style={[styles.touchableButtonImage]} role="button" @@ -134,7 +136,7 @@ function HeaderWithBackButton({ > @@ -150,12 +152,12 @@ function HeaderWithBackButton({ > )} - {shouldShowPinButton && } + {shouldShowPinButton && !!report && } {shouldShowThreeDotsButton && ( @@ -186,7 +188,6 @@ function HeaderWithBackButton({ ); } -HeaderWithBackButton.propTypes = headerWithBackButtonPropTypes; HeaderWithBackButton.displayName = 'HeaderWithBackButton'; export default HeaderWithBackButton; diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts new file mode 100644 index 000000000000..99e93e8d18d2 --- /dev/null +++ b/src/components/HeaderWithBackButton/types.ts @@ -0,0 +1,113 @@ +import {ReactNode} from 'react'; +import {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {Action} from '@hooks/useSingleExecution'; +import type {StepCounterParams} from '@src/languages/types'; +import type {AnchorPosition} from '@src/styles'; +import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type ThreeDotsMenuItems = { + /** An icon element displayed on the left side */ + icon?: IconAsset; + + /** Text label */ + text: string; + + /** A callback triggered when the item is selected */ + onSelected: () => void; +}; + +type HeaderWithBackButtonProps = Partial & { + /** Title of the Header */ + title?: string; + + /** Subtitle of the header */ + subtitle?: ReactNode; + + /** Title color */ + titleColor?: string; + + /** Method to trigger when pressing download button of the header */ + onDownloadButtonPress?: () => void; + + /** Method to trigger when pressing close button of the header */ + onCloseButtonPress?: () => void; + + /** Method to trigger when pressing back button of the header */ + onBackButtonPress?: () => void; + + /** Method to trigger when pressing more options button of the header */ + onThreeDotsButtonPress?: () => void; + + /** Whether we should show a border on the bottom of the Header */ + shouldShowBorderBottom?: boolean; + + /** Whether we should show a download button */ + shouldShowDownloadButton?: boolean; + + /** Whether we should show a get assistance (question mark) button */ + shouldShowGetAssistanceButton?: boolean; + + /** Whether we should disable the get assistance button */ + shouldDisableGetAssistanceButton?: boolean; + + /** Whether we should show a pin button */ + shouldShowPinButton?: boolean; + + /** Whether we should show a more options (threedots) button */ + shouldShowThreeDotsButton?: boolean; + + /** Whether we should disable threedots button */ + shouldDisableThreeDotsButton?: boolean; + + /** List of menu items for more(three dots) menu */ + threeDotsMenuItems?: ThreeDotsMenuItems[]; + + /** The anchor position of the menu */ + threeDotsAnchorPosition?: AnchorPosition; + + /** Whether we should show a close button */ + shouldShowCloseButton?: boolean; + + /** Whether we should show a back button */ + shouldShowBackButton?: boolean; + + /** The guides call taskID to associate with the get assistance button, if we show it */ + guidesCallTaskID?: string; + + /** Data to display a step counter in the header */ + stepCounter?: StepCounterParams; + + /** Whether we should show an avatar */ + shouldShowAvatarWithDisplay?: boolean; + + /** Parent report, if provided it will override props.report for AvatarWithDisplay */ + parentReport?: OnyxEntry; + + /** Report, if we're showing the details for one and using AvatarWithDisplay */ + report?: OnyxEntry; + + /** The report's policy, if we're showing the details for a report and need info about it for AvatarWithDisplay */ + policy?: OnyxEntry; + + /** Policies, if we're showing the details for a report and need participant details for AvatarWithDisplay */ + personalDetails?: OnyxCollection; + + /** Single execution function to prevent concurrent navigation actions */ + singleExecution?: (action: Action) => Action; + + /** Whether we should navigate to report page when the route have a topMostReport */ + shouldNavigateToTopMostReport?: boolean; + + /** The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */ + iconFill?: string; + + /** Whether the popover menu should overlay the current view */ + shouldOverlay?: boolean; + + /** Whether we should enable detail page navigation */ + shouldEnableDetailPageNavigation?: boolean; +}; + +export default HeaderWithBackButtonProps; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index b0690211d6c5..1e574504001d 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -25,6 +25,7 @@ import SafeBlue from '@assets/images/product-illustrations/safe.svg'; import SmartScan from '@assets/images/product-illustrations/simple-illustration__smartscan.svg'; import TadaBlue from '@assets/images/product-illustrations/tada--blue.svg'; import TadaYellow from '@assets/images/product-illustrations/tada--yellow.svg'; +import TeleScope from '@assets/images/product-illustrations/telescope.svg'; import ToddBehindCloud from '@assets/images/product-illustrations/todd-behind-cloud.svg'; import BankArrow from '@assets/images/simple-illustrations/simple-illustration__bank-arrow.svg'; import BigRocket from '@assets/images/simple-illustrations/simple-illustration__bigrocket.svg'; @@ -110,4 +111,5 @@ export { Hands, HandEarth, SmartScan, + TeleScope, }; diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx index e71d21077eda..732fe90deae2 100644 --- a/src/components/Icon/index.tsx +++ b/src/components/Icon/index.tsx @@ -1,15 +1,14 @@ import {ImageContentFit} from 'expo-image'; -import React, {PureComponent} from 'react'; +import React from 'react'; import {StyleProp, View, ViewStyle} from 'react-native'; import ImageSVG from '@components/ImageSVG'; -import withStyleUtils, {WithStyleUtilsProps} from '@components/withStyleUtils'; -import withTheme, {WithThemeProps} from '@components/withTheme'; -import withThemeStyles, {type WithThemeStylesProps} from '@components/withThemeStyles'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import IconAsset from '@src/types/utils/IconAsset'; import IconWrapperStyles from './IconWrapperStyles'; -type IconBaseProps = { +type IconProps = { /** The asset to render. */ src: IconAsset; @@ -43,68 +42,65 @@ type IconBaseProps = { /** Determines how the image should be resized to fit its container */ contentFit?: ImageContentFit; }; -type IconProps = IconBaseProps & WithThemeStylesProps & WithThemeProps & WithStyleUtilsProps; - -// We must use a class component to create an animatable component with the Animated API -// eslint-disable-next-line react/prefer-stateless-function -class Icon extends PureComponent { - // eslint-disable-next-line react/static-property-placement - public static defaultProps: Partial = { - width: variables.iconSizeNormal, - height: variables.iconSizeNormal, - fill: undefined, - small: false, - inline: false, - additionalStyles: [], - hovered: false, - pressed: false, - testID: '', - contentFit: 'cover', - }; - - render() { - const width = this.props.small ? variables.iconSizeSmall : this.props.width; - const height = this.props.small ? variables.iconSizeSmall : this.props.height; - const iconStyles = [this.props.StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, this.props.themeStyles.pAbsolute, this.props.additionalStyles]; - - if (this.props.inline) { - return ( - - - - - - ); - } +function Icon({ + src, + width = variables.iconSizeNormal, + height = variables.iconSizeNormal, + fill = undefined, + small = false, + inline = false, + additionalStyles = [], + hovered = false, + pressed = false, + testID = '', + contentFit = 'cover', +}: IconProps) { + const StyleUtils = useStyleUtils(); + const styles = useThemeStyles(); + const iconWidth = small ? variables.iconSizeSmall : width; + const iconHeight = small ? variables.iconSizeSmall : height; + const iconStyles = [StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, styles.pAbsolute, additionalStyles]; + + if (inline) { return ( - + + + ); } + + return ( + + + + ); } -export default withTheme(withThemeStyles(withStyleUtils(Icon))); +Icon.displayName = 'Icon'; + +export default Icon; diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index f0bb4e3ffd53..1c66cef234ed 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -31,6 +31,13 @@ function getTextMatrix(text: string): string[][] { return text.split('\n').map((row) => row.split(CONST.REGEX.SPACE_OR_EMOJI).filter((value) => value !== '')); } +/** + * Validates if the text contains any emoji + */ +function containsEmoji(text: string): boolean { + return CONST.REGEX.EMOJI.test(text); +} + function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { const styles = useThemeStyles(); @@ -53,7 +60,7 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { style={styles.codeWordWrapper} > - {colText} + {colText} ))} diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.js b/src/components/InvertedFlatList/BaseInvertedFlatList.js deleted file mode 100644 index 4206d5086a9e..000000000000 --- a/src/components/InvertedFlatList/BaseInvertedFlatList.js +++ /dev/null @@ -1,38 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {forwardRef} from 'react'; -import FlatList from '@components/FlatList'; - -const AUTOSCROLL_TO_TOP_THRESHOLD = 128; - -const propTypes = { - /** Same as FlatList can be any array of anything */ - // eslint-disable-next-line react/forbid-prop-types - data: PropTypes.arrayOf(PropTypes.any), - - /** Same as FlatList although we wrap it in a measuring helper before passing to the actual FlatList component */ - renderItem: PropTypes.func.isRequired, -}; - -const defaultProps = { - data: [], -}; - -const BaseInvertedFlatList = forwardRef((props, ref) => ( - -)); - -BaseInvertedFlatList.propTypes = propTypes; -BaseInvertedFlatList.defaultProps = defaultProps; -BaseInvertedFlatList.displayName = 'BaseInvertedFlatList'; - -export default BaseInvertedFlatList; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx new file mode 100644 index 000000000000..08d990583572 --- /dev/null +++ b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx @@ -0,0 +1,26 @@ +import React, {ForwardedRef, forwardRef} from 'react'; +import {FlatListProps} from 'react-native'; +import FlatList from '@components/FlatList'; + +const AUTOSCROLL_TO_TOP_THRESHOLD = 128; +const WINDOW_SIZE = 15; + +function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef) { + return ( + + ); +} + +BaseInvertedFlatList.displayName = 'BaseInvertedFlatList'; + +export default forwardRef(BaseInvertedFlatList); diff --git a/src/components/InvertedFlatList/CellRendererComponent.js b/src/components/InvertedFlatList/CellRendererComponent.tsx similarity index 62% rename from src/components/InvertedFlatList/CellRendererComponent.js rename to src/components/InvertedFlatList/CellRendererComponent.tsx index 2b2d214000bf..252d47989064 100644 --- a/src/components/InvertedFlatList/CellRendererComponent.js +++ b/src/components/InvertedFlatList/CellRendererComponent.tsx @@ -1,20 +1,12 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import {View} from 'react-native'; +import {StyleProp, View, ViewProps} from 'react-native'; -const propTypes = { - /** Position index of the list item in a list view */ - index: PropTypes.number.isRequired, - - /** Styles that are passed to the component */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), -}; - -const defaultProps = { - style: {}, +type CellRendererComponentProps = ViewProps & { + index: number; + style?: StyleProp; }; -function CellRendererComponent(props) { +function CellRendererComponent(props: CellRendererComponentProps) { return ( { +function BaseInvertedFlatListWithRef(props: FlatListProps, ref: ForwardedRef) { const styles = useThemeStyles(); return ( { removeClippedSubviews={false} /> ); -}); +} BaseInvertedFlatListWithRef.displayName = 'BaseInvertedFlatListWithRef'; -export default BaseInvertedFlatListWithRef; +export default forwardRef(BaseInvertedFlatListWithRef); diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.tsx similarity index 53% rename from src/components/InvertedFlatList/index.js rename to src/components/InvertedFlatList/index.tsx index 815b58ad8308..6871b010a385 100644 --- a/src/components/InvertedFlatList/index.js +++ b/src/components/InvertedFlatList/index.tsx @@ -1,58 +1,37 @@ -import PropTypes from 'prop-types'; -import React, {forwardRef, useEffect, useRef} from 'react'; -import {DeviceEventEmitter, FlatList} from 'react-native'; +import React, {ForwardedRef, forwardRef, useEffect, useRef} from 'react'; +import {DeviceEventEmitter, FlatList, FlatListProps, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import CONST from '@src/CONST'; import BaseInvertedFlatList from './BaseInvertedFlatList'; -const propTypes = { - /** Passed via forwardRef so we can access the FlatList ref */ - innerRef: PropTypes.shape({ - current: PropTypes.instanceOf(FlatList), - }).isRequired, - - /** Any additional styles to apply */ - // eslint-disable-next-line react/forbid-prop-types - contentContainerStyle: PropTypes.any, - - /** Same as for FlatList */ - onScroll: PropTypes.func, -}; - // This is adapted from https://codesandbox.io/s/react-native-dsyse // It's a HACK alert since FlatList has inverted scrolling on web -function InvertedFlatList(props) { - const {innerRef, contentContainerStyle} = props; - - const lastScrollEvent = useRef(null); - const scrollEndTimeout = useRef(null); - const updateInProgress = useRef(false); - const eventHandler = useRef(null); +function InvertedFlatList({onScroll: onScrollProp = () => {}, contentContainerStyle, ...props}: FlatListProps, ref: ForwardedRef) { + const lastScrollEvent = useRef(null); + const scrollEndTimeout = useRef(null); + const updateInProgress = useRef(false); useEffect( () => () => { - if (scrollEndTimeout.current) { - clearTimeout(scrollEndTimeout.current); - } - - if (eventHandler.current) { - eventHandler.current.remove(); + if (!scrollEndTimeout.current) { + return; } + clearTimeout(scrollEndTimeout.current); }, - [innerRef], + [ref], ); /** * Emits when the scrolling is in progress. Also, * invokes the onScroll callback function from props. * - * @param {Event} event - The onScroll event from the FlatList + * @param event - The onScroll event from the FlatList */ - const onScroll = (event) => { - props.onScroll(event); + const onScroll = (event: NativeSyntheticEvent) => { + onScrollProp(event); if (!updateInProgress.current) { updateInProgress.current = true; - eventHandler.current = DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, true); + DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, true); } }; @@ -60,7 +39,7 @@ function InvertedFlatList(props) { * Emits when the scrolling has ended. */ const onScrollEnd = () => { - eventHandler.current = DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, false); + DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, false); updateInProgress.current = false; }; @@ -77,9 +56,8 @@ function InvertedFlatList(props) { * This workaround is taken from below and refactored to fit our needs: * https://github.com/necolas/react-native-web/issues/1021#issuecomment-984151185 * - * @param {Event} event - The onScroll event from the FlatList */ - const handleScroll = (event) => { + const handleScroll = (event: NativeSyntheticEvent) => { onScroll(event); const timestamp = Date.now(); @@ -105,28 +83,13 @@ function InvertedFlatList(props) { ); } -InvertedFlatList.propTypes = propTypes; -InvertedFlatList.defaultProps = { - contentContainerStyle: {}, - onScroll: () => {}, -}; InvertedFlatList.displayName = 'InvertedFlatList'; -const InvertedFlatListWithRef = forwardRef((props, ref) => ( - -)); - -InvertedFlatListWithRef.displayName = 'InvertedFlatListWithRef'; - -export default InvertedFlatListWithRef; +export default forwardRef(InvertedFlatList); diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index f75e3390136a..fc4f05eefd22 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -135,7 +135,7 @@ function OptionRowLHN(props) { props.reportID, '0', props.reportID, - '', + undefined, () => {}, () => setIsContextMenuActive(false), false, diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx index a20cdcff4e10..3e235a2fc88a 100644 --- a/src/components/MentionSuggestions.tsx +++ b/src/components/MentionSuggestions.tsx @@ -72,7 +72,7 @@ function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSe size={isIcon ? CONST.AVATAR_SIZE.MENTION_ICON : CONST.AVATAR_SIZE.SMALLER} name={item.icons[0].name} type={item.icons[0].type} - fill={theme.success} + fill={isIcon ? theme.success : undefined} fallbackIcon={item.icons[0].fallbackIcon} /> diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 63181e4aea87..b75f4e2df845 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -267,6 +267,9 @@ function MoneyRequestConfirmationList(props) { return (props.hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction)); }, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]); + const isMerchantEmpty = !props.iouMerchant || props.iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + const shouldDisplayMerchantError = props.isPolicyExpenseChat && !props.isScanRequest && isMerchantEmpty; + useEffect(() => { if (shouldDisplayFieldError && props.hasSmartScanFailed) { setFormError('iou.receiptScanningFailed'); @@ -500,7 +503,7 @@ function MoneyRequestConfirmationList(props) { } const shouldShowSettlementButton = props.iouType === CONST.IOU.TYPE.SEND; - const shouldDisableButton = selectedParticipants.length === 0; + const shouldDisableButton = selectedParticipants.length === 0 || shouldDisplayMerchantError; const button = shouldShowSettlementButton ? ( )} {shouldShowCategories && ( diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 20012bc90ef0..dab34e324ffa 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -97,6 +97,9 @@ const propTypes = { /** Should the list be read only, and not editable? */ isReadOnly: PropTypes.bool, + /** Whether the money request is a scan request */ + isScanRequest: PropTypes.bool, + /** Depending on expense report or personal IOU report, respective bank account route */ bankAccountRoute: PropTypes.string, @@ -211,6 +214,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ isEditingSplitBill, isPolicyExpenseChat, isReadOnly, + isScanRequest, listStyles, mileageRate, onConfirm, @@ -281,6 +285,8 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const [didConfirm, setDidConfirm] = useState(false); const [didConfirmSplit, setDidConfirmSplit] = useState(false); + const [merchantError, setMerchantError] = useState(false); + const shouldDisplayFieldError = useMemo(() => { if (!isEditingSplitBill) { return false; @@ -289,6 +295,21 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ return (hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction)); }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]); + const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; + const isMerchantRequired = isPolicyExpenseChat && !isScanRequest && shouldShowMerchant; + + useEffect(() => { + if ((!isMerchantRequired && isMerchantEmpty) || !merchantError) { + return; + } + if (!isMerchantEmpty && merchantError) { + setMerchantError(false); + if (formError === 'iou.error.invalidMerchant') { + setFormError(''); + } + } + }, [formError, isMerchantEmpty, merchantError, isMerchantRequired]); + useEffect(() => { if (shouldDisplayFieldError && hasSmartScanFailed) { setFormError('iou.receiptScanningFailed'); @@ -298,9 +319,13 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ setFormError('iou.error.genericSmartscanFailureMessage'); return; } + if (merchantError) { + setFormError('iou.error.invalidMerchant'); + return; + } // reset the form error whenever the screen gains or loses focus setFormError(''); - }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); + }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isMerchantRequired, merchantError]); useEffect(() => { if (!shouldCalculateDistanceAmount) { @@ -470,6 +495,10 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ if (_.isEmpty(selectedParticipants)) { return; } + if ((isMerchantRequired && isMerchantEmpty) || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction))) { + setMerchantError(true); + return; + } if (iouType === CONST.IOU.TYPE.SEND) { if (!paymentMethod) { @@ -498,7 +527,21 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ onConfirm(selectedParticipants); } }, - [selectedParticipants, onSendMoney, onConfirm, isEditingSplitBill, iouType, isDistanceRequest, isDistanceRequestWithoutRoute, iouCurrencyCode, iouAmount, transaction], + [ + selectedParticipants, + isMerchantRequired, + isMerchantEmpty, + shouldDisplayFieldError, + transaction, + iouType, + onSendMoney, + iouCurrencyCode, + isDistanceRequest, + isDistanceRequestWithoutRoute, + iouAmount, + isEditingSplitBill, + onConfirm, + ], ); const footerContent = useMemo(() => { @@ -551,7 +594,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ {button} ); - }, [confirm, bankAccountRoute, iouCurrencyCode, iouType, isReadOnly, policyID, selectedParticipants, splitOrRequestOptions, translate, formError, styles.ph1, styles.mb2]); + }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2, translate]); const {image: receiptImage, thumbnail: receiptThumbnail} = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : {}; return ( @@ -629,6 +672,26 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ interactive={!isReadOnly} numberOfLinesTitle={2} /> + {isMerchantRequired && ( + { + if (isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.MERCHANT)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={merchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={merchantError ? translate('common.error.fieldRequired') : ''} + /> + )} {!shouldShowAllFields && ( @@ -680,10 +743,10 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ interactive={!isReadOnly} /> )} - {shouldShowMerchant && ( + {!isMerchantRequired && shouldShowMerchant && ( )} {shouldShowCategories && ( diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 5fcf1fe7442b..29fd0c2700dc 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -18,13 +18,13 @@ import MessagesRow from './MessagesRow'; type OfflineWithFeedbackProps = ChildrenProps & { /** The type of action that's pending */ - pendingAction: OnyxCommon.PendingAction; + pendingAction?: OnyxCommon.PendingAction; /** Determine whether to hide the component's children if deletion is pending */ shouldHideOnDelete?: boolean; /** The errors to display */ - errors?: OnyxCommon.Errors; + errors?: OnyxCommon.Errors | null; /** Whether we should show the error messages */ shouldShowErrorMessages?: boolean; @@ -56,7 +56,7 @@ type OfflineWithFeedbackProps = ChildrenProps & { type StrikethroughProps = Partial & {style: Array}; -function omitBy(obj: Record | undefined, predicate: (value: T) => boolean) { +function omitBy(obj: Record | undefined | null, predicate: (value: T) => boolean) { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars return Object.fromEntries(Object.entries(obj ?? {}).filter(([_, value]) => !predicate(value))); } diff --git a/src/components/OptionRow.js b/src/components/OptionRow.tsx similarity index 57% rename from src/components/OptionRow.js rename to src/components/OptionRow.tsx index c31ed7af1e90..5a2f6902c4c0 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.tsx @@ -1,13 +1,13 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import lodashIsEqual from 'lodash/isEqual'; import React, {useEffect, useRef, useState} from 'react'; -import {InteractionManager, StyleSheet, View} from 'react-native'; -import _ from 'underscore'; +import {InteractionManager, StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import Button from './Button'; import DisplayNames from './DisplayNames'; @@ -16,159 +16,156 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import MultipleAvatars from './MultipleAvatars'; import OfflineWithFeedback from './OfflineWithFeedback'; -import optionPropTypes from './optionPropTypes'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; import SelectCircle from './SelectCircle'; import SubscriptAvatar from './SubscriptAvatar'; import Text from './Text'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -const propTypes = { +type OptionRowProps = { /** Style for hovered state */ - // eslint-disable-next-line react/forbid-prop-types - hoverStyle: PropTypes.object, + hoverStyle?: StyleProp; /** Option to allow the user to choose from can be type 'report' or 'user' */ - option: optionPropTypes.isRequired, + option: OptionData; /** Whether this option is currently in focus so we can modify its style */ - optionIsFocused: PropTypes.bool, + optionIsFocused?: boolean; /** A function that is called when an option is selected. Selected option is passed as a param */ - onSelectRow: PropTypes.func, + onSelectRow?: (option: OptionData, refElement: View | HTMLDivElement | null) => void | Promise; /** Whether we should show the selected state */ - showSelectedState: PropTypes.bool, + showSelectedState?: boolean; /** Whether to show a button pill instead of a tickbox */ - shouldShowSelectedStateAsButton: PropTypes.bool, + shouldShowSelectedStateAsButton?: boolean; /** Text for button pill */ - selectedStateButtonText: PropTypes.string, + selectedStateButtonText?: string; /** Callback to fire when the multiple selector (tickbox or button) is clicked */ - onSelectedStatePressed: PropTypes.func, + onSelectedStatePressed?: (option: OptionData) => void; /** Whether we highlight selected option */ - highlightSelected: PropTypes.bool, + highlightSelected?: boolean; /** Whether this item is selected */ - isSelected: PropTypes.bool, + isSelected?: boolean; /** Display the text of the option in bold font style */ - boldStyle: PropTypes.bool, + boldStyle?: boolean; /** Whether to show the title tooltip */ - showTitleTooltip: PropTypes.bool, + showTitleTooltip?: boolean; /** Whether this option should be disabled */ - isDisabled: PropTypes.bool, + isDisabled?: boolean; /** Whether to show a line separating options in list */ - shouldHaveOptionSeparator: PropTypes.bool, + shouldHaveOptionSeparator?: boolean; /** Whether to remove the lateral padding and align the content with the margins */ - shouldDisableRowInnerPadding: PropTypes.bool, + shouldDisableRowInnerPadding?: boolean; /** Whether to prevent default focusing on select */ - shouldPreventDefaultFocusOnSelectRow: PropTypes.bool, + shouldPreventDefaultFocusOnSelectRow?: boolean; /** Whether to wrap large text up to 2 lines */ - isMultilineSupported: PropTypes.bool, + isMultilineSupported?: boolean; - /** Key used internally by React */ - keyForList: PropTypes.string, - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + /** Display name and alternate text style */ + style?: StyleProp; - ...withLocalizePropTypes, -}; + /** Hovered background color */ + backgroundColor?: string; -const defaultProps = { - hoverStyle: undefined, - showSelectedState: false, - shouldShowSelectedStateAsButton: false, - selectedStateButtonText: 'Select', - onSelectedStatePressed: () => {}, - highlightSelected: false, - isSelected: false, - boldStyle: false, - showTitleTooltip: false, - onSelectRow: undefined, - isDisabled: false, - optionIsFocused: false, - isMultilineSupported: false, - style: null, - shouldHaveOptionSeparator: false, - shouldDisableRowInnerPadding: false, - shouldPreventDefaultFocusOnSelectRow: false, - keyForList: undefined, + /** Key used internally by React */ + keyForList?: string; }; -function OptionRow(props) { +function OptionRow({ + option, + onSelectRow, + style, + hoverStyle, + selectedStateButtonText, + keyForList, + isDisabled: isOptionDisabled = false, + isMultilineSupported = false, + shouldShowSelectedStateAsButton = false, + highlightSelected = false, + shouldHaveOptionSeparator = false, + showTitleTooltip = false, + optionIsFocused = false, + boldStyle = false, + onSelectedStatePressed = () => {}, + backgroundColor, + isSelected = false, + showSelectedState = false, + shouldDisableRowInnerPadding = false, + shouldPreventDefaultFocusOnSelectRow = false, +}: OptionRowProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const pressableRef = useRef(null); - const [isDisabled, setIsDisabled] = useState(props.isDisabled); + const {translate} = useLocalize(); + const pressableRef = useRef(null); + const [isDisabled, setIsDisabled] = useState(isOptionDisabled); useEffect(() => { - setIsDisabled(props.isDisabled); - }, [props.isDisabled]); + setIsDisabled(isOptionDisabled); + }, [isOptionDisabled]); - const text = lodashGet(props.option, 'text', ''); - const fullTitle = props.isMultilineSupported ? text.trimStart() : text; + const text = option.text ?? ''; + const fullTitle = isMultilineSupported ? text.trimStart() : text; const indentsLength = text.length - fullTitle.length; const paddingLeft = Math.floor(indentsLength / CONST.INDENTS.length) * styles.ml3.marginLeft; - const textStyle = props.optionIsFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; - const textUnreadStyle = props.boldStyle || props.option.boldStyle ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; - const displayNameStyle = StyleUtils.combineStyles( + const textStyle = optionIsFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; + const textUnreadStyle = boldStyle || option.boldStyle ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; + const displayNameStyle: StyleProp = [ styles.optionDisplayName, textUnreadStyle, - props.style, + style, styles.pre, isDisabled ? styles.optionRowDisabled : {}, - props.isMultilineSupported ? {paddingLeft} : {}, - ); - const alternateTextStyle = StyleUtils.combineStyles( + isMultilineSupported ? {paddingLeft} : {}, + ]; + const alternateTextStyle: StyleProp = [ textStyle, styles.optionAlternateText, styles.textLabelSupporting, - props.style, - lodashGet(props.option, 'alternateTextMaxLines', 1) === 1 ? styles.pre : styles.preWrap, - ); + style, + (option.alternateTextMaxLines ?? 1) === 1 ? styles.pre : styles.preWrap, + ]; const contentContainerStyles = [styles.flex1]; const sidebarInnerRowStyle = StyleSheet.flatten([styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRow, styles.justifyContentCenter]); - const hoveredBackgroundColor = - (props.hoverStyle || styles.sidebarLinkHover) && (props.hoverStyle || styles.sidebarLinkHover).backgroundColor - ? (props.hoverStyle || styles.sidebarLinkHover).backgroundColor - : props.backgroundColor; + const flattenHoverStyle = StyleSheet.flatten(hoverStyle); + const hoveredStyle = hoverStyle ? flattenHoverStyle : styles.sidebarLinkHover; + const hoveredBackgroundColor = hoveredStyle?.backgroundColor ? (hoveredStyle.backgroundColor as string) : backgroundColor; const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; - const isMultipleParticipant = lodashGet(props.option, 'participantsList.length', 0) > 1; + const isMultipleParticipant = (option.participantsList?.length ?? 0) > 1; // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips( - (props.option.participantsList || (props.option.accountID ? [props.option] : [])).slice(0, 10), - isMultipleParticipant, - ); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((option.participantsList ?? (option.accountID ? [option] : [])).slice(0, 10), isMultipleParticipant); let subscriptColor = theme.appBG; - if (props.optionIsFocused) { + if (optionIsFocused) { subscriptColor = focusedBackgroundColor; } return ( {(hovered) => ( (pressableRef.current = el)} + nativeID={keyForList} + ref={pressableRef} onPress={(e) => { - if (!props.onSelectRow) { + if (!onSelectRow) { return; } @@ -176,12 +173,13 @@ function OptionRow(props) { if (e) { e.preventDefault(); } - let result = props.onSelectRow(props.option, pressableRef.current); + let result = onSelectRow(option, pressableRef.current); if (!(result instanceof Promise)) { result = Promise.resolve(); } + InteractionManager.runAfterInteractions(() => { - result.finally(() => setIsDisabled(props.isDisabled)); + result?.finally(() => setIsDisabled(isOptionDisabled)); }); }} disabled={isDisabled} @@ -190,68 +188,64 @@ function OptionRow(props) { styles.alignItemsCenter, styles.justifyContentBetween, styles.sidebarLink, - !props.isDisabled && styles.cursorPointer, - props.shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner, - props.optionIsFocused ? styles.sidebarLinkActive : null, - props.shouldHaveOptionSeparator && styles.borderTop, - !props.onSelectRow && !props.isDisabled ? styles.cursorDefault : null, + !isOptionDisabled && styles.cursorPointer, + shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner, + optionIsFocused ? styles.sidebarLinkActive : null, + shouldHaveOptionSeparator && styles.borderTop, + !onSelectRow && !isOptionDisabled ? styles.cursorDefault : null, ]} - accessibilityLabel={props.option.text} + accessibilityLabel={option.text} role={CONST.ROLE.BUTTON} hoverDimmingValue={1} - hoverStyle={!props.optionIsFocused ? props.hoverStyle || styles.sidebarLinkHover : undefined} - needsOffscreenAlphaCompositing={lodashGet(props.option, 'icons.length', 0) >= 2} - onMouseDown={props.shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} + hoverStyle={!optionIsFocused ? hoverStyle ?? styles.sidebarLinkHover : undefined} + needsOffscreenAlphaCompositing={(option.icons?.length ?? 0) >= 2} + onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (event) => event.preventDefault() : undefined} > - {!_.isEmpty(props.option.icons) && - (props.option.shouldShowSubscript ? ( + {!!option.icons?.length && + (option.shouldShowSubscript ? ( ) : ( ))} - {props.option.alternateText ? ( + {option.alternateText ? ( - {props.option.alternateText} + {option.alternateText} ) : null} - {props.option.descriptiveText ? ( + {option.descriptiveText ? ( - {props.option.descriptiveText} + {option.descriptiveText} ) : null} - {props.option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( + {option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( )} - {props.showSelectedState && ( + {showSelectedState && ( <> - {props.shouldShowSelectedStateAsButton && !props.isSelected ? ( + {shouldShowSelectedStateAsButton && !isSelected ? (