diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index ca7345ef9462..4a53e75354c6 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -1,20 +1,23 @@ -# Deploying the ExpensifyHelp Jekyll site by dynamically generating routes file name: Deploy ExpensifyHelp on: - # Runs on pushes targeting the default branch + # Run on any push to main that has changes to the docs directory push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab + branches: + - main + paths: + - 'docs/**' + + # Run on any pull request (except PRs against staging or production) that has changes to the docs directory + pull_request: + types: [opened, synchronize] + branches-ignore: [staging, production] + paths: + - 'docs/**' + + # Run on any manual trigger workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: @@ -22,7 +25,6 @@ concurrency: cancel-in-progress: false jobs: - # Build job build: runs-on: ubuntu-latest steps: @@ -32,9 +34,6 @@ jobs: - name: Setup NodeJS uses: Expensify/App/.github/actions/composite/setupNode@main - - name: Setup Pages - uses: actions/configure-pages@f156874f8191504dae5b037505266ed5dda6c382 - - name: Create docs routes file run: ./.github/scripts/createDocsRoutes.sh @@ -44,19 +43,18 @@ jobs: source: ./docs/ destination: ./docs/_site - - name: Upload artifact - uses: actions/upload-pages-artifact@64bcae551a7b18bcb9a09042ddf1960979799187 + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca + id: deploy with: - path: ./docs/_site - - # Deployment job - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@af48cf94a42f2c634308b1c9dc0151830b6f190a + apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: helpdot + directory: ./docs/_site + + - name: Leave a comment on the PR + uses: actions-cool/maintain-one-comment@de04bd2a3750d86b324829a3ff34d47e48e16f4b + if: ${{ github.event_name == 'pull_request' }} + with: + token: ${{ secrets.OS_BOTIFY_TOKEN }} + body: ${{ format('A preview of your ExpensifyHelp changes have been deployed to {0} ⚡️', steps.deploy.outputs.alias) }} diff --git a/android/app/build.gradle b/android/app/build.gradle index fa2bd3865ca2..2a3499e6d005 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001038708 - versionName "1.3.87-8" + versionCode 1001038803 + versionName "1.3.88-3" } flavorDimensions "default" diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md b/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md index 3ee1c8656b4b..5bbd2c4b583c 100644 --- a/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md @@ -1,5 +1,41 @@ --- -title: Coming Soon -description: Coming Soon +title: Expensify and TSheets/QuickBooks Time Integration Guide +description: This help document explains how to connect TSheets/QuickBooks Time to your Expensify policy --- -## Resource Coming Soon! +# Overview + +Connecting Expensify with TSheets/QuickBooks Time can streamline your expense tracking and time management processes. This integration allows you to automatically sync time entries from TSheets/QuickBooks Time with expenses in Expensify, ensuring accurate and efficient expense reporting. + +# How to set up the Expensify and TSheets/QuickBooks Time integration + +Before you begin, make sure you have the following: + +- **Expensify account:** You must have an active Expensify account. +- **TSheets account:** You must have a TSheets account and admin privileges to set up the integration. + +Now, follow these steps to set up the integration: + +1. Log into your Expensify account on your web browser + +2. Go to **Settings > Workspaces > Group > Workspace Name > Connections > TSheets** + +3. Click **Connect to TSheets** + +4. Follow the on-screen instructions to sign in to your TSheets account and grant Expensify access. + +5. Once the integration is authorized, you may need to configure some preferences. +- Specify how you want TSheets time entries to be imported into Expensify. You can typically customize settings like the date range, project/task mapping, and expense categories. + +6. Now, we’d recommend testing the integration. +- Create a sample time entry in TSheets and check if it’s automatically reflected in your Expensify account. + +7. If the test is successful, save your integration settings. + +8. You may also want to schedule regular syncs or specify how often Expensify should pull data from TSheets. + +With the integration set up, your TSheets time entries will now appear in Expensify as expenses. You can review, categorize, and submit these expenses as needed. + +Congratulations! You've successfully integrated Expensify with TSheets, simplifying your expense tracking and reporting process. + +For questions, don't hesitate to reach out to concierge@expensify.com or chat directly with your account manager + diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md b/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md b/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 95a9a26df7f6..2e9862727e25 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.87 + 1.3.88 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.87.8 + 1.3.88.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index d41b75440036..1961c8157c4f 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.87 + 1.3.88 CFBundleSignature ???? CFBundleVersion - 1.3.87.8 + 1.3.88.3 diff --git a/package-lock.json b/package-lock.json index 65c2be0529e3..39220723706d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.87-8", + "version": "1.3.88-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.87-8", + "version": "1.3.88-3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index aca3dc508c41..2ba8a358b947 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.87-8", + "version": "1.3.88-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.ts b/src/CONST.ts index 048c2dee5bab..aa09c5772f11 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -124,7 +124,16 @@ const CONST = { VIEW_HEIGHT: 275, }, MONEY_REPORT: { - MIN_HEIGHT: 280, + SMALL_SCREEN: { + IMAGE_HEIGHT: 300, + CONTAINER_MINHEIGHT: 280, + VIEW_HEIGHT: 220, + }, + WIDE_SCREEN: { + IMAGE_HEIGHT: 450, + CONTAINER_MINHEIGHT: 280, + VIEW_HEIGHT: 275, + }, }, }, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b5ceb8fc557d..a2308cb56ef1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -5,6 +5,16 @@ import CONST from './CONST'; * This is a file containing constants for all of the routes we want to be able to go to */ +/** + * This is a file containing constants for all of the routes we want to be able to go to + * Returns an encoded URI component for the backTo param which can be added to the end of URLs + * @param backTo + * @returns + */ +function getBackToParam(backTo?: string): string { + return backTo ? `?backTo=${encodeURIComponent(backTo)}` : ''; +} + export default { HOME: '', /** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */ @@ -20,10 +30,7 @@ export default { }, PROFILE: { route: 'a/:accountID', - getRoute: (accountID: string | number, backTo = '') => { - const backToParam = backTo ? `?backTo=${encodeURIComponent(backTo)}` : ''; - return `a/${accountID}${backToParam}`; - }, + getRoute: (accountID: string | number, backTo?: string) => `a/${accountID}${getBackToParam(backTo)}`, }, TRANSITION_BETWEEN_APPS: 'transition', @@ -49,10 +56,7 @@ export default { BANK_ACCOUNT_PERSONAL: 'bank-account/personal', BANK_ACCOUNT_WITH_STEP_TO_OPEN: { route: 'bank-account/:stepToOpen?', - getRoute: (stepToOpen = '', policyID = '', backTo = ''): string => { - const backToParam = backTo ? `&backTo=${encodeURIComponent(backTo)}` : ''; - return `bank-account/${stepToOpen}?policyID=${policyID}${backToParam}`; - }, + getRoute: (stepToOpen = '', policyID = '', backTo?: string): string => `bank-account/${stepToOpen}?policyID=${policyID}${getBackToParam(backTo)}`, }, SETTINGS: 'settings', @@ -104,13 +108,7 @@ export default { SETTINGS_PERSONAL_DETAILS_ADDRESS: 'settings/profile/personal-details/address', SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY: { route: 'settings/profile/personal-details/address/country', - getRoute: (country: string, backTo?: string) => { - let route = `settings/profile/personal-details/address/country?country=${country}`; - if (backTo) { - route += `&backTo=${encodeURIComponent(backTo)}`; - } - return route; - }, + getRoute: (country: string, backTo?: string) => `settings/profile/personal-details/address/country?country=${country}${getBackToParam(backTo)}`, }, SETTINGS_CONTACT_METHODS: 'settings/profile/contact-methods', SETTINGS_CONTACT_METHOD_DETAILS: { diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index a12b089ddf97..8b32234fdbdf 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -1,12 +1,13 @@ import React, {useState, useEffect, useRef, forwardRef, useImperativeHandle} from 'react'; import {Dimensions} from 'react-native'; import _ from 'underscore'; +import PropTypes from 'prop-types'; import EmojiPickerMenu from './EmojiPickerMenu'; import CONST from '../../CONST'; import styles from '../../styles/styles'; import PopoverWithMeasuredContent from '../PopoverWithMeasuredContent'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; -import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../withViewportOffsetTop'; +import withViewportOffsetTop from '../withViewportOffsetTop'; import compose from '../../libs/compose'; import * as StyleUtils from '../../styles/StyleUtils'; import calculateAnchorPosition from '../../libs/calculateAnchorPosition'; @@ -18,7 +19,7 @@ const DEFAULT_ANCHOR_ORIGIN = { const propTypes = { ...windowDimensionsPropTypes, - ...viewportOffsetTopPropTypes, + viewportOffsetTop: PropTypes.number.isRequired, }; const EmojiPicker = forwardRef((props, ref) => { diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index 0d7826ff3783..df47d3494928 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -108,7 +108,6 @@ function EmojiPickerMenu(props) { const [selection, setSelection] = useState({start: 0, end: 0}); const [isFocused, setIsFocused] = useState(false); const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false); - const [selectTextOnFocus, setSelectTextOnFocus] = useState(false); useEffect(() => { const emojisAndHeaderRowIndices = getEmojisAndHeaderRowIndices(); @@ -162,8 +161,6 @@ function EmojiPickerMenu(props) { if (!searchInputRef.current) { return; } - - setSelectTextOnFocus(true); searchInputRef.current.focus(); } @@ -323,11 +320,7 @@ function EmojiPickerMenu(props) { // We allow typing in the search box if any key is pressed apart from Arrow keys. if (searchInputRef.current && !searchInputRef.current.isFocused()) { - setSelectTextOnFocus(false); searchInputRef.current.focus(); - - // Re-enable selection on the searchInput - setSelectTextOnFocus(true); } }, [filteredEmojis, highlightAdjacentEmoji, highlightedIndex, isFocused, onEmojiSelected, preferredSkinTone], @@ -371,13 +364,17 @@ function EmojiPickerMenu(props) { } setupEventHandlers(); - updateFirstNonHeaderIndex(emojis.current); return () => { cleanupEventHandlers(); }; }, [forwardedRef, shouldFocusInputOnScreenFocus, cleanupEventHandlers, setupEventHandlers]); + useEffect(() => { + // Find and store index of the first emoji item on mount + updateFirstNonHeaderIndex(emojis.current); + }, []); + const scrollToHeader = useCallback((headerIndex) => { if (!emojiListRef.current) { return; @@ -481,7 +478,6 @@ function EmojiPickerMenu(props) { defaultValue="" ref={searchInputRef} autoFocus={shouldFocusInputOnScreenFocus} - selectTextOnFocus={selectTextOnFocus} onSelectionChange={onSelectionChange} onFocus={() => { setHighlightedIndex(-1); diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js index 8ae4672e758e..ab0b77c21653 100644 --- a/src/components/MoneyReportHeader.js +++ b/src/components/MoneyReportHeader.js @@ -121,10 +121,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt shouldShowPaymentOptions style={[styles.pv2]} formattedAmount={formattedAmount} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, - }} /> )} diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 5ca08bf82f89..a0923d1214ec 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -506,7 +506,11 @@ function MoneyRequestConfirmationList(props) { policyID={props.policyID} shouldShowPaymentOptions buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} - anchorAlignment={{ + kycWallAnchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + paymentMethodDropdownAnchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} diff --git a/src/components/PDFView/PDFPasswordForm.js b/src/components/PDFView/PDFPasswordForm.js index 58a4e64a28a5..6b6163992589 100644 --- a/src/components/PDFView/PDFPasswordForm.js +++ b/src/components/PDFView/PDFPasswordForm.js @@ -131,7 +131,7 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat autoCorrect={false} textContentType="password" onChangeText={updatePassword} - returnKeyType="done" + returnKeyType="go" onSubmitEditing={submitPassword} errorText={errorText} onFocus={() => onPasswordFieldFocused(true)} diff --git a/src/components/QRCode/index.js b/src/components/QRCode/index.js deleted file mode 100644 index f27cf28066ef..000000000000 --- a/src/components/QRCode/index.js +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import QRCodeLibrary from 'react-native-qrcode-svg'; -import PropTypes from 'prop-types'; -import defaultTheme from '../../styles/themes/default'; -import CONST from '../../CONST'; - -const propTypes = { - /** - * The QR code URL - */ - url: PropTypes.string.isRequired, - /** - * The logo which will be displayed in the middle of the QR code. - * Follows ImageProps href from react-native-svg that is used by react-native-qrcode-svg. - */ - logo: PropTypes.oneOfType([PropTypes.shape({uri: PropTypes.string}), PropTypes.number, PropTypes.string]), - /** - * The size ratio of logo to QR code - */ - logoRatio: PropTypes.number, - /** - * The size ratio of margin around logo to QR code - */ - logoMarginRatio: PropTypes.number, - /** - * The QRCode size - */ - size: PropTypes.number, - /** - * The QRCode color - */ - color: PropTypes.string, - /** - * The QRCode background color - */ - backgroundColor: PropTypes.string, - /** - * Function to retrieve the internal component ref and be able to call it's - * methods - */ - getRef: PropTypes.func, -}; - -const defaultProps = { - logo: undefined, - size: 120, - color: defaultTheme.text, - backgroundColor: defaultTheme.highlightBG, - getRef: undefined, - logoRatio: CONST.QR.DEFAULT_LOGO_SIZE_RATIO, - logoMarginRatio: CONST.QR.DEFAULT_LOGO_MARGIN_RATIO, -}; - -function QRCode(props) { - return ( - - ); -} - -QRCode.displayName = 'QRCode'; -QRCode.propTypes = propTypes; -QRCode.defaultProps = defaultProps; - -export default QRCode; diff --git a/src/components/QRCode/index.tsx b/src/components/QRCode/index.tsx new file mode 100644 index 000000000000..bca45c02fffa --- /dev/null +++ b/src/components/QRCode/index.tsx @@ -0,0 +1,71 @@ +import React, {Ref} from 'react'; +import QRCodeLibrary from 'react-native-qrcode-svg'; +import {ImageSourcePropType} from 'react-native'; +import defaultTheme from '../../styles/themes/default'; +import CONST from '../../CONST'; + +type LogoRatio = typeof CONST.QR.DEFAULT_LOGO_SIZE_RATIO | typeof CONST.QR.EXPENSIFY_LOGO_SIZE_RATIO; + +type LogoMarginRatio = typeof CONST.QR.DEFAULT_LOGO_MARGIN_RATIO | typeof CONST.QR.EXPENSIFY_LOGO_MARGIN_RATIO; + +type QRCodeProps = { + /** The QR code URL */ + url: string; + + /** + * The logo which will be displayed in the middle of the QR code. + * Follows ImageProps href from react-native-svg that is used by react-native-qrcode-svg. + */ + logo?: ImageSourcePropType; + + /** The size ratio of logo to QR code */ + logoRatio?: LogoRatio; + + /** The size ratio of margin around logo to QR code */ + logoMarginRatio?: LogoMarginRatio; + + /** The QRCode size */ + size?: number; + + /** The QRCode color */ + color?: string; + + /** The QRCode background color */ + backgroundColor?: string; + + /** + * Function to retrieve the internal component ref and be able to call it's + * methods + */ + getRef?: (ref: Ref) => Ref; +}; + +function QRCode({ + url, + logo, + getRef, + size = 120, + color = defaultTheme.text, + backgroundColor = defaultTheme.highlightBG, + logoRatio = CONST.QR.DEFAULT_LOGO_SIZE_RATIO, + logoMarginRatio = CONST.QR.DEFAULT_LOGO_MARGIN_RATIO, +}: QRCodeProps) { + return ( + + ); +} + +QRCode.displayName = 'QRCode'; + +export default QRCode; diff --git a/src/components/ReportActionItem/MoneyReportView.js b/src/components/ReportActionItem/MoneyReportView.js index 9bc7a35f9fba..3f9b8bf53837 100644 --- a/src/components/ReportActionItem/MoneyReportView.js +++ b/src/components/ReportActionItem/MoneyReportView.js @@ -7,7 +7,6 @@ import styles from '../../styles/styles'; import themeColors from '../../styles/themes/default'; import * as ReportUtils from '../../libs/ReportUtils'; import * as StyleUtils from '../../styles/StyleUtils'; -import CONST from '../../CONST'; import Text from '../Text'; import Icon from '../Icon'; import * as Expensicons from '../Icon/Expensicons'; @@ -34,16 +33,16 @@ function MoneyReportView(props) { const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(props.report); const shouldShowBreakdown = nonReimbursableSpend && reimbursableSpend; - const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, props.report.currency); + const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, props.report.currency, ReportUtils.hasOnlyDistanceRequestTransactions(props.report.reportID)); const formattedOutOfPocketAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, props.report.currency); const formattedCompanySpendAmount = CurrencyUtils.convertToDisplayString(nonReimbursableSpend, props.report.currency); const subAmountTextStyles = [styles.taskTitleMenuItem, styles.alignSelfCenter, StyleUtils.getFontSizeStyle(variables.fontSizeh1), StyleUtils.getColorStyle(themeColors.textSupporting)]; return ( - + - + - - {shouldShowBreakdown ? ( - <> - - - - {translate('cardTransactions.outOfPocket')} - - - - - {formattedOutOfPocketAmount} - - - - - - - {translate('cardTransactions.companySpend')} - + {shouldShowBreakdown ? ( + <> + + + + {translate('cardTransactions.outOfPocket')} + + + + + {formattedOutOfPocketAmount} + + - - - {formattedCompanySpendAmount} - + + + + {translate('cardTransactions.companySpend')} + + + + + {formattedCompanySpendAmount} + + - - - ) : undefined} - + + ) : undefined} + + ); } diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 985d88e53d88..bdeec2640cdc 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -123,6 +123,7 @@ function ReportPreview(props) { const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(props.iouReportID); const numberOfScanningReceipts = _.filter(transactionsWithReceipts, (transaction) => TransactionUtils.isReceiptBeingScanned(transaction)).length; const hasReceipts = transactionsWithReceipts.length > 0; + const hasOnlyDistanceRequests = ReportUtils.hasOnlyDistanceRequestTransactions(props.iouReportID); const isScanning = hasReceipts && ReportUtils.areAllRequestsBeingSmartScanned(props.iouReportID, props.action); const hasErrors = hasReceipts && ReportUtils.hasMissingSmartscanFields(props.iouReportID); const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); @@ -145,6 +146,9 @@ function ReportPreview(props) { if (isScanning) { return props.translate('iou.receiptScanning'); } + if (hasOnlyDistanceRequests) { + return props.translate('common.tbd'); + } // If iouReport is not available, get amount from the action message (Ex: "Domain20821's Workspace owes $33.00" or "paid ₫60" or "paid -₫60 elsewhere") let displayAmount = ''; @@ -241,9 +245,13 @@ function ReportPreview(props) { onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)} enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} - style={[styles.mt3]} shouldShowPaymentOptions - anchorAlignment={{ + style={[styles.mt3]} + kycWallAnchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + paymentMethodDropdownAnchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index 287f3210b14d..2989fd103850 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -70,8 +70,14 @@ const propTypes = { /** Whether we should show a loading state for the main button */ isLoading: PropTypes.bool, - /** The anchor alignment of the popover menu */ - anchorAlignment: PropTypes.shape({ + /** The anchor alignment of the popover menu for payment method dropdown */ + paymentMethodDropdownAnchorAlignment: PropTypes.shape({ + horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), + vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), + }), + + /** The anchor alignment of the popover menu for KYC wall popover */ + kycWallAnchorAlignment: PropTypes.shape({ horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), }), @@ -96,8 +102,12 @@ const defaultProps = { policyID: '', formattedAmount: '', buttonSize: CONST.DROPDOWN_BUTTON_SIZE.MEDIUM, - anchorAlignment: { - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + kycWallAnchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, // button is at left, so horizontal anchor is at LEFT + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP + }, + paymentMethodDropdownAnchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, // caret for dropdown is at right, so horizontal anchor is at RIGHT vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP }, }; @@ -105,7 +115,8 @@ const defaultProps = { function SettlementButton({ addDebitCardRoute, addBankAccountRoute, - anchorAlignment, + kycWallAnchorAlignment, + paymentMethodDropdownAnchorAlignment, betas, buttonSize, chatReportID, @@ -210,7 +221,7 @@ function SettlementButton({ source={CONST.KYC_WALL_SOURCE.REPORT} chatReportID={chatReportID} iouReport={iouReport} - anchorAlignment={anchorAlignment} + anchorAlignment={kycWallAnchorAlignment} > {(triggerKYCFlow, buttonRef) => ( )} diff --git a/src/components/SwipeableView/index.js b/src/components/SwipeableView/index.js deleted file mode 100644 index 96640b107608..000000000000 --- a/src/components/SwipeableView/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export default ({children}) => children; - -// Swipeable View is available just on Android/iOS for now. diff --git a/src/components/SwipeableView/index.native.js b/src/components/SwipeableView/index.native.tsx similarity index 65% rename from src/components/SwipeableView/index.native.js rename to src/components/SwipeableView/index.native.tsx index 2f1148721af1..ac500f025016 100644 --- a/src/components/SwipeableView/index.native.js +++ b/src/components/SwipeableView/index.native.tsx @@ -1,41 +1,34 @@ import React, {useRef} from 'react'; import {PanResponder, View} from 'react-native'; -import PropTypes from 'prop-types'; import CONST from '../../CONST'; +import SwipeableViewProps from './types'; -const propTypes = { - children: PropTypes.element.isRequired, - - /** Callback to fire when the user swipes down on the child content */ - onSwipeDown: PropTypes.func.isRequired, -}; - -function SwipeableView(props) { +function SwipeableView({children, onSwipeDown}: SwipeableViewProps) { const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT; const oldYRef = useRef(0); const panResponder = useRef( PanResponder.create({ - // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance - // & swipe direction is downwards + // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards + // eslint-disable-next-line @typescript-eslint/naming-convention onMoveShouldSetPanResponderCapture: (_event, gestureState) => { if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) { return true; } oldYRef.current = gestureState.dy; + return false; }, // Calls the callback when the swipe down is released; after the completion of the gesture - onPanResponderRelease: props.onSwipeDown, + onPanResponderRelease: onSwipeDown, }), ).current; return ( // eslint-disable-next-line react/jsx-props-no-spreading - {props.children} + {children} ); } -SwipeableView.propTypes = propTypes; SwipeableView.displayName = 'SwipeableView'; export default SwipeableView; diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx new file mode 100644 index 000000000000..335c3e7dcf03 --- /dev/null +++ b/src/components/SwipeableView/index.tsx @@ -0,0 +1,4 @@ +import SwipeableViewProps from './types'; + +// Swipeable View is available just on Android/iOS for now. +export default ({children}: SwipeableViewProps) => children; diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts new file mode 100644 index 000000000000..560df7ef5a45 --- /dev/null +++ b/src/components/SwipeableView/types.ts @@ -0,0 +1,11 @@ +import {ReactNode} from 'react'; + +type SwipeableViewProps = { + /** The content to be rendered within the SwipeableView */ + children: ReactNode; + + /** Callback to fire when the user swipes down on the child content */ + onSwipeDown: () => void; +}; + +export default SwipeableViewProps; diff --git a/src/components/ZeroWidthView/index.js b/src/components/ZeroWidthView/index.js new file mode 100644 index 000000000000..6c3809a40a04 --- /dev/null +++ b/src/components/ZeroWidthView/index.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import * as EmojiUtils from '../../libs/EmojiUtils'; +import * as Browser from '../../libs/Browser'; +import Text from '../Text'; + +const propTypes = { + /** If this is the Concierge chat, we'll open the modal for requesting a setup call instead of showing popover menu */ + text: PropTypes.string, + + /** URL to the assigned guide's appointment booking calendar */ + displayAsGroup: PropTypes.bool, +}; + +const defaultProps = { + text: '', + displayAsGroup: false, +}; + +function ZeroWidthView({text, displayAsGroup}) { + const firstLetterIsEmoji = EmojiUtils.isFirstLetterEmoji(text); + if (firstLetterIsEmoji && !displayAsGroup && !Browser.isMobile()) { + return ; + } + return null; +} + +ZeroWidthView.propTypes = propTypes; +ZeroWidthView.defaultProps = defaultProps; +ZeroWidthView.displayName = 'ZeroWidthView'; + +export default ZeroWidthView; diff --git a/src/components/ZeroWidthView/index.native.js b/src/components/ZeroWidthView/index.native.js new file mode 100644 index 000000000000..59c3cc74ab72 --- /dev/null +++ b/src/components/ZeroWidthView/index.native.js @@ -0,0 +1,5 @@ +function ZeroWidthView() { + return null; +} + +export default ZeroWidthView; diff --git a/src/components/withCurrentUserPersonalDetails.js b/src/components/withCurrentUserPersonalDetails.js deleted file mode 100644 index 7a47ea7cc712..000000000000 --- a/src/components/withCurrentUserPersonalDetails.js +++ /dev/null @@ -1,74 +0,0 @@ -import React, {useMemo} from 'react'; -import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; -import getComponentDisplayName from '../libs/getComponentDisplayName'; -import ONYXKEYS from '../ONYXKEYS'; -import personalDetailsPropType from '../pages/personalDetailsPropType'; -import refPropTypes from './refPropTypes'; - -const withCurrentUserPersonalDetailsPropTypes = { - currentUserPersonalDetails: personalDetailsPropType, -}; - -const withCurrentUserPersonalDetailsDefaultProps = { - currentUserPersonalDetails: {}, -}; - -export default function (WrappedComponent) { - const propTypes = { - forwardedRef: refPropTypes, - - /** Personal details of all the users, including current user */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - - /** Session of the current user */ - session: PropTypes.shape({ - accountID: PropTypes.number, - }), - }; - const defaultProps = { - forwardedRef: undefined, - personalDetails: {}, - session: { - accountID: 0, - }, - }; - - function WithCurrentUserPersonalDetails(props) { - const accountID = props.session.accountID; - const accountPersonalDetails = props.personalDetails[accountID]; - const currentUserPersonalDetails = useMemo(() => ({...accountPersonalDetails, accountID}), [accountPersonalDetails, accountID]); - return ( - - ); - } - - WithCurrentUserPersonalDetails.displayName = `WithCurrentUserPersonalDetails(${getComponentDisplayName(WrappedComponent)})`; - WithCurrentUserPersonalDetails.propTypes = propTypes; - - WithCurrentUserPersonalDetails.defaultProps = defaultProps; - - const withCurrentUserPersonalDetails = React.forwardRef((props, ref) => ( - - )); - - return withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - })(withCurrentUserPersonalDetails); -} - -export {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps}; diff --git a/src/components/withCurrentUserPersonalDetails.tsx b/src/components/withCurrentUserPersonalDetails.tsx new file mode 100644 index 000000000000..e1472f280f17 --- /dev/null +++ b/src/components/withCurrentUserPersonalDetails.tsx @@ -0,0 +1,67 @@ +import React, {ComponentType, RefAttributes, ForwardedRef, useMemo} from 'react'; +import {OnyxEntry, withOnyx} from 'react-native-onyx'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; +import ONYXKEYS from '../ONYXKEYS'; +import personalDetailsPropType from '../pages/personalDetailsPropType'; +import type {PersonalDetails, Session} from '../types/onyx'; + +type CurrentUserPersonalDetails = PersonalDetails | Record; + +type OnyxProps = { + /** Personal details of all the users, including current user */ + personalDetails: OnyxEntry>; + + /** Session of the current user */ + session: OnyxEntry; +}; + +type HOCProps = { + currentUserPersonalDetails: CurrentUserPersonalDetails; +}; + +type ComponentProps = OnyxProps & HOCProps; + +// TODO: remove when all components that use it will be migrated to TS +const withCurrentUserPersonalDetailsPropTypes = { + currentUserPersonalDetails: personalDetailsPropType, +}; + +const withCurrentUserPersonalDetailsDefaultProps: HOCProps = { + currentUserPersonalDetails: {}, +}; + +export default function ( + WrappedComponent: ComponentType>, +): ComponentType & RefAttributes, keyof OnyxProps>> { + function WithCurrentUserPersonalDetails(props: Omit, ref: ForwardedRef) { + const accountID = props.session?.accountID ?? 0; + const accountPersonalDetails = props.personalDetails?.[accountID]; + const currentUserPersonalDetails: CurrentUserPersonalDetails = useMemo( + () => (accountPersonalDetails ? {...accountPersonalDetails, accountID} : {}), + [accountPersonalDetails, accountID], + ); + return ( + + ); + } + + WithCurrentUserPersonalDetails.displayName = `WithCurrentUserPersonalDetails(${getComponentDisplayName(WrappedComponent)})`; + + const withCurrentUserPersonalDetails = React.forwardRef(WithCurrentUserPersonalDetails); + + return withOnyx & RefAttributes, OnyxProps>({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + session: { + key: ONYXKEYS.SESSION, + }, + })(withCurrentUserPersonalDetails); +} + +export {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps}; diff --git a/src/components/withViewportOffsetTop.js b/src/components/withViewportOffsetTop.js deleted file mode 100644 index ccf928b3bd13..000000000000 --- a/src/components/withViewportOffsetTop.js +++ /dev/null @@ -1,61 +0,0 @@ -import React, {useEffect, forwardRef, useState} from 'react'; -import PropTypes from 'prop-types'; -import lodashGet from 'lodash/get'; -import getComponentDisplayName from '../libs/getComponentDisplayName'; -import addViewportResizeListener from '../libs/VisualViewport'; -import refPropTypes from './refPropTypes'; - -const viewportOffsetTopPropTypes = { - // viewportOffsetTop returns the offset of the top edge of the visual viewport from the - // top edge of the layout viewport in CSS pixels, when the visual viewport is resized. - - viewportOffsetTop: PropTypes.number.isRequired, -}; - -export default function (WrappedComponent) { - function WithViewportOffsetTop(props) { - const [viewportOffsetTop, setViewportOffsetTop] = useState(0); - - useEffect(() => { - /** - * @param {SyntheticEvent} e - */ - const updateDimensions = (e) => { - const targetOffsetTop = lodashGet(e, 'target.offsetTop', 0); - setViewportOffsetTop(targetOffsetTop); - }; - - const removeViewportResizeListener = addViewportResizeListener(updateDimensions); - - return () => { - removeViewportResizeListener(); - }; - }, []); - - return ( - - ); - } - - WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`; - WithViewportOffsetTop.propTypes = { - forwardedRef: refPropTypes, - }; - WithViewportOffsetTop.defaultProps = { - forwardedRef: undefined, - }; - return forwardRef((props, ref) => ( - - )); -} - -export {viewportOffsetTopPropTypes}; diff --git a/src/components/withViewportOffsetTop.tsx b/src/components/withViewportOffsetTop.tsx new file mode 100644 index 000000000000..e2e1dc2d3484 --- /dev/null +++ b/src/components/withViewportOffsetTop.tsx @@ -0,0 +1,41 @@ +import React, {useEffect, forwardRef, useState, ComponentType, RefAttributes, ForwardedRef} from 'react'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; +import addViewportResizeListener from '../libs/VisualViewport'; + +type ViewportOffsetTopProps = { + // viewportOffsetTop returns the offset of the top edge of the visual viewport from the + // top edge of the layout viewport in CSS pixels, when the visual viewport is resized. + viewportOffsetTop: number; +}; + +export default function withViewportOffsetTop(WrappedComponent: ComponentType>) { + function WithViewportOffsetTop(props: Omit, ref: ForwardedRef) { + const [viewportOffsetTop, setViewportOffsetTop] = useState(0); + + useEffect(() => { + const updateDimensions = (event: Event) => { + const targetOffsetTop = (event.target instanceof VisualViewport && event.target.offsetTop) || 0; + setViewportOffsetTop(targetOffsetTop); + }; + + const removeViewportResizeListener = addViewportResizeListener(updateDimensions); + + return () => { + removeViewportResizeListener(); + }; + }, []); + + return ( + + ); + } + + WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`; + + return forwardRef(WithViewportOffsetTop); +} diff --git a/src/hooks/useCopySelectionHelper.js b/src/hooks/useCopySelectionHelper.ts similarity index 89% rename from src/hooks/useCopySelectionHelper.js rename to src/hooks/useCopySelectionHelper.ts index 42871981e29c..b41bfb3c4aee 100644 --- a/src/hooks/useCopySelectionHelper.js +++ b/src/hooks/useCopySelectionHelper.ts @@ -25,10 +25,12 @@ export default function useCopySelectionHelper() { copyShortcutConfig.shortcutKey, copySelectionToClipboard, copyShortcutConfig.descriptionKey, - copyShortcutConfig.modifiers, + [...copyShortcutConfig.modifiers], false, ); - return unsubscribeCopyShortcut; + return () => { + unsubscribeCopyShortcut(); + }; }, []); } diff --git a/src/languages/en.ts b/src/languages/en.ts index 11637846130a..bc6bba610475 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -584,6 +584,7 @@ export default { threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money`, + categorySelection: 'Select a category to add additional organization to your money', error: { invalidAmount: 'Please enter a valid amount before continuing.', invalidSplit: 'Split amounts do not equal total amount', diff --git a/src/languages/es.ts b/src/languages/es.ts index e4a5c37241f2..df34eb0b1759 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -578,6 +578,7 @@ export default { threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero`, + categorySelection: 'Seleccione una categoría para organizar mejor tu dinero', error: { invalidAmount: 'Por favor ingresa un monto válido antes de continuar.', invalidSplit: 'La suma de las partes no equivale al monto total', diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 21784d450a07..85ba8340c13e 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -2,6 +2,7 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS, {OnyxValues} from '../ONYXKEYS'; import CONST from '../CONST'; import BaseLocaleListener from './Localize/LocaleListener/BaseLocaleListener'; +import * as Localize from './Localize'; import * as NumberFormatUtils from './NumberFormatUtils'; let currencyList: OnyxValues[typeof ONYXKEYS.CURRENCY_LIST] = {}; @@ -96,8 +97,13 @@ function convertToFrontendAmount(amountAsInt: number): number { * * @param amountInCents – should be an integer. Anything after a decimal place will be dropped. * @param currency - IOU currency + * @param shouldFallbackToTbd - whether to return 'TBD' instead of a falsy value (e.g. 0.00) */ -function convertToDisplayString(amountInCents: number, currency: string = CONST.CURRENCY.USD): string { +function convertToDisplayString(amountInCents: number, currency: string = CONST.CURRENCY.USD, shouldFallbackToTbd = false): string { + if (shouldFallbackToTbd && !amountInCents) { + return Localize.translateLocal('common.tbd'); + } + const convertedAmount = convertToFrontendAmount(amountInCents); return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', diff --git a/src/libs/Navigation/OnyxTabNavigator.js b/src/libs/Navigation/OnyxTabNavigator.js index 2782054497b0..158160e9aa13 100644 --- a/src/libs/Navigation/OnyxTabNavigator.js +++ b/src/libs/Navigation/OnyxTabNavigator.js @@ -6,13 +6,13 @@ import Tab from '../actions/Tab'; import ONYXKEYS from '../../ONYXKEYS'; const propTypes = { - /* ID of the tab component to be saved in onyx */ + /** ID of the tab component to be saved in onyx */ id: PropTypes.string.isRequired, - /* Name of the selected tab */ + /** Name of the selected tab */ selectedTab: PropTypes.string, - /* Children nodes */ + /** Children nodes */ children: PropTypes.node.isRequired, }; diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js index de902b53a7a4..2ecc818ebd23 100644 --- a/src/libs/PolicyUtils.js +++ b/src/libs/PolicyUtils.js @@ -161,7 +161,7 @@ const isPolicyAdmin = (policy) => lodashGet(policy, 'role') === CONST.POLICY.ROL * @param {Object} policies * @returns {Boolean} */ -const isPolicyMember = (policyID, policies) => _.some(policies, (policy) => policy.id === policyID); +const isPolicyMember = (policyID, policies) => _.some(policies, (policy) => lodashGet(policy, 'id') === policyID); /** * @param {Object} policyMembers diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index c795e5d1c3b1..d016e220e147 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1,4 +1,3 @@ -import {isEqual, max} from 'date-fns'; import _ from 'lodash'; import lodashFindLast from 'lodash/findLast'; import Onyx, {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; @@ -370,24 +369,19 @@ function replaceBaseURL(reportAction: ReportAction): ReportAction { /** */ function getLastVisibleAction(reportID: string, actionsToMerge: ReportActions = {}): OnyxEntry { - const updatedActionsToMerge: ReportActions = {}; + let reportActions: ReportActions; if (actionsToMerge && Object.keys(actionsToMerge).length !== 0) { - Object.keys(actionsToMerge).forEach( - (actionToMergeID) => (updatedActionsToMerge[actionToMergeID] = {...allReportActions?.[reportID]?.[actionToMergeID], ...actionsToMerge[actionToMergeID]}), - ); - } - const actions = Object.values({ - ...allReportActions?.[reportID], - ...updatedActionsToMerge, - }); - const visibleActions = actions.filter((action) => shouldReportActionBeVisibleAsLastAction(action)); - - if (visibleActions.length === 0) { + reportActions = {...allReportActions?.[reportID]}; + Object.keys(actionsToMerge).forEach((actionToMergeID) => (reportActions[actionToMergeID] = {...allReportActions?.[reportID]?.[actionToMergeID], ...actionsToMerge[actionToMergeID]})); + } else { + reportActions = allReportActions?.[reportID] ?? {}; + } + const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => shouldReportActionBeVisibleAsLastAction(action)); + const sortedReportActions = getSortedReportActions(visibleReportActions, true); + if (sortedReportActions.length === 0) { return null; } - const maxDate = max(visibleActions.map((action) => new Date(action.created))); - const maxAction = visibleActions.find((action) => isEqual(new Date(action.created), maxDate)); - return maxAction ?? null; + return sortedReportActions[0]; } function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = {}): LastVisibleMessage { diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 5cf7396c6669..93774afc0aef 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -660,6 +660,17 @@ function hasSingleParticipant(report) { return report && report.participantAccountIDs && report.participantAccountIDs.length === 1; } +/** + * Checks whether all the transactions linked to the IOU report are of the Distance Request type + * + * @param {string|null} iouReportID + * @returns {boolean} + */ +function hasOnlyDistanceRequestTransactions(iouReportID) { + const allTransactions = TransactionUtils.getAllReportTransactions(iouReportID); + return _.all(allTransactions, (transaction) => TransactionUtils.isDistanceRequest(transaction)); +} + /** * If the report is a thread and has a chat type set, it is a workspace chat. * @@ -1415,7 +1426,7 @@ function getPolicyExpenseChatName(report, policy = undefined) { } /** - * Get the title for a IOU or expense chat which will be showing the payer and the amount + * Get the title for an IOU or expense chat which will be showing the payer and the amount * * @param {Object} report * @param {Object} [policy] @@ -1423,15 +1434,15 @@ function getPolicyExpenseChatName(report, policy = undefined) { */ function getMoneyRequestReportName(report, policy = undefined) { const moneyRequestTotal = getMoneyRequestReimbursableTotal(report); - const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report.currency); + const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report.currency, hasOnlyDistanceRequestTransactions(report.reportID)); const payerName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report.managerID); - const payerPaidAmountMesssage = Localize.translateLocal('iou.payerPaidAmount', { + const payerPaidAmountMessage = Localize.translateLocal('iou.payerPaidAmount', { payer: payerName, amount: formattedAmount, }); if (report.isWaitingOnBankAccount) { - return `${payerPaidAmountMesssage} • ${Localize.translateLocal('iou.pending')}`; + return `${payerPaidAmountMessage} • ${Localize.translateLocal('iou.pending')}`; } if (hasNonReimbursableTransactions(report.reportID)) { @@ -1442,7 +1453,7 @@ function getMoneyRequestReportName(report, policy = undefined) { return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName, amount: formattedAmount}); } - return payerPaidAmountMesssage; + return payerPaidAmountMessage; } /** @@ -1527,7 +1538,7 @@ function canEditReportAction(reportAction) { /** * Gets all transactions on an IOU report with a receipt * - * @param {Object|null} iouReportID + * @param {string|null} iouReportID * @returns {[Object]} */ function getTransactionsWithReceipts(iouReportID) { @@ -1593,7 +1604,7 @@ function getTransactionReportName(reportAction) { const {amount, currency, comment} = getTransactionDetails(transaction); return Localize.translateLocal(ReportActionsUtils.isSentMoneyReportAction(reportAction) ? 'iou.threadSentMoneyReportName' : 'iou.threadRequestReportName', { - formattedAmount: CurrencyUtils.convertToDisplayString(amount, currency), + formattedAmount: CurrencyUtils.convertToDisplayString(amount, currency, TransactionUtils.isDistanceRequest(transaction)), comment, }); } @@ -3392,8 +3403,16 @@ function parseReportRouteParams(route) { } const pathSegments = parsingRoute.split('/'); + + const reportIDSegment = pathSegments[1]; + + // Check for "undefined" or any other unwanted string values + if (!reportIDSegment || reportIDSegment === 'undefined') { + return {reportID: '', isSubReportPageRoute: false}; + } + return { - reportID: pathSegments[1], + reportID: reportIDSegment, isSubReportPageRoute: pathSegments.length > 2, }; } @@ -3710,6 +3729,21 @@ function getPolicyExpenseChatReportIDByOwner(policyOwner) { return expenseChat.reportID; } +/** + * Check if the report can create the request with type is iouType + * @param {Object} report + * @param {Array} betas + * @param {String} iouType + * @returns {Boolean} + */ +function canCreateRequest(report, betas, iouType) { + const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []); + if (shouldDisableWriteActions(report)) { + return false; + } + return getMoneyRequestOptions(report, participantAccountIDs, betas).includes(iouType); +} + /** * @param {String} policyID * @param {Array} accountIDs @@ -4039,6 +4073,7 @@ export { getCommentLength, getParsedComment, getMoneyRequestOptions, + canCreateRequest, hasIOUWaitingOnCurrentUserBankAccount, canRequestMoney, getWhisperDisplayNames, @@ -4079,6 +4114,7 @@ export { buildTransactionThread, areAllRequestsBeingSmartScanned, getTransactionsWithReceipts, + hasOnlyDistanceRequestTransactions, hasNonReimbursableTransactions, hasMissingSmartscanFields, getIOUReportActionDisplayMessage, diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 5f1114d4b03e..44f8094ca13d 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -414,7 +414,9 @@ function getWaypointIndex(key: string): number { * Filters the waypoints which are valid and returns those */ function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = false): WaypointCollection { - const sortedIndexes = Object.keys(waypoints).map(getWaypointIndex).sort(); + const sortedIndexes = Object.keys(waypoints) + .map(getWaypointIndex) + .sort((a, b) => a - b); const waypointValues = sortedIndexes.map((index) => waypoints[`waypoint${index}`]); // Ensure the number of waypoints is between 2 and 25 if (waypointValues.length < 2 || waypointValues.length > 25) { diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index bf4f170f1ba7..cc755f10dfc6 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -145,7 +145,7 @@ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAcc /** * Adds a bank account via Plaid * - * @TODO offline pattern for this command will have to be added later once the pattern B design doc is complete + * TODO: offline pattern for this command will have to be added later once the pattern B design doc is complete */ function addPersonalBankAccount(account: PlaidBankAccount) { const commandName = 'AddPersonalBankAccount'; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 07e814f92884..a157c54c8002 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1054,7 +1054,8 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(participant); // In case the participant is a workspace, email & accountID should remain undefined and won't be used in the rest of this code - const email = isOwnPolicyExpenseChat || isPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login).toLowerCase(); + // participant.login is undefined when the request is initiated from a group DM with an unknown user, so we need to add a default + const email = isOwnPolicyExpenseChat || isPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login || '').toLowerCase(); const accountID = isOwnPolicyExpenseChat || isPolicyExpenseChat ? 0 : Number(participant.accountID); if (email === currentUserEmailForIOUSplit) { return; diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index e4a1b763cd62..090e6205e925 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -89,8 +89,6 @@ function DetailsPage(props) { let details = _.find(props.personalDetails, (detail) => detail.login === login.toLowerCase()); if (!details) { - // TODO: these personal details aren't in my local test account but are in - // my staging account, i wonder why! if (login === CONST.EMAIL.CONCIERGE) { details = { accountID: CONST.ACCOUNT_ID.CONCIERGE, diff --git a/src/pages/EditRequestCategoryPage.js b/src/pages/EditRequestCategoryPage.js index e47935dd9df1..c0db5a16b140 100644 --- a/src/pages/EditRequestCategoryPage.js +++ b/src/pages/EditRequestCategoryPage.js @@ -4,6 +4,8 @@ import ScreenWrapper from '../components/ScreenWrapper'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; import Navigation from '../libs/Navigation/Navigation'; import useLocalize from '../hooks/useLocalize'; +import styles from '../styles/styles'; +import Text from '../components/Text'; import CategoryPicker from '../components/CategoryPicker'; const propTypes = { @@ -36,7 +38,7 @@ function EditRequestCategoryPage({defaultCategory, policyID, onSubmit}) { title={translate('common.category')} onBackButtonPress={Navigation.goBack} /> - + {translate('iou.categorySelection')} - + {translate('iou.tagSelection', {tagName: tagName || translate('common.tag')})} `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, diff --git a/src/pages/RoomInvitePage.js b/src/pages/RoomInvitePage.js index c923a8d96d70..5021ccdc42d7 100644 --- a/src/pages/RoomInvitePage.js +++ b/src/pages/RoomInvitePage.js @@ -250,7 +250,7 @@ RoomInvitePage.defaultProps = defaultProps; RoomInvitePage.displayName = 'RoomInvitePage'; export default compose( - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, diff --git a/src/pages/RoomMembersPage.js b/src/pages/RoomMembersPage.js index 87e1afab8ae9..3a6e3b6fd90f 100644 --- a/src/pages/RoomMembersPage.js +++ b/src/pages/RoomMembersPage.js @@ -316,7 +316,7 @@ RoomMembersPage.displayName = 'RoomMembersPage'; export default compose( withLocalize, withWindowDimensions, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 4eaf1c1ce15c..81000c2dab92 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -26,7 +26,7 @@ import Banner from '../../components/Banner'; import reportPropTypes from '../reportPropTypes'; import reportMetadataPropTypes from '../reportMetadataPropTypes'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; -import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop'; +import withViewportOffsetTop from '../../components/withViewportOffsetTop'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; import personalDetailsPropType from '../personalDetailsPropType'; import getIsReportFullyVisible from '../../libs/getIsReportFullyVisible'; @@ -94,7 +94,7 @@ const propTypes = { /** Whether user is leaving the current report */ userLeavingStatus: PropTypes.bool, - ...viewportOffsetTopPropTypes, + viewportOffsetTop: PropTypes.number.isRequired, ...withCurrentReportIDPropTypes, }; @@ -317,7 +317,7 @@ function ReportScreen({ prevOnyxReportID === routeReportID && !onyxReportID && prevReport.statusNum === CONST.REPORT.STATUS.OPEN && - (report.statusNum === CONST.REPORT.STATUS.CLOSED || !report.statusNum)) + (report.statusNum === CONST.REPORT.STATUS.CLOSED || (!report.statusNum && !prevReport.parentReportID))) ) { Navigation.dismissModal(); if (Navigation.getTopmostReportId() === prevOnyxReportID) { diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index ffd7f65185ce..e194d0870885 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -566,6 +566,7 @@ function ComposerWithSuggestions({ { - if (shouldBlockCalc.current || selectionEnd < 1) { + if (shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) { shouldBlockCalc.current = false; resetSuggestions(); return; @@ -229,7 +230,7 @@ function SuggestionMention({ })); setHighlightedMentionIndex(0); }, - [getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value], + [getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value, isComposerFocused], ); useEffect(() => { diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 60c31efb1446..74e9e79471e7 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -40,6 +40,7 @@ function Suggestions({ resetKeyboardInput, measureParentContainer, isAutoSuggestionPickerLarge, + isComposerFocused, }) { const suggestionEmojiRef = useRef(null); const suggestionMentionRef = useRef(null); @@ -103,6 +104,7 @@ function Suggestions({ composerHeight, isAutoSuggestionPickerLarge, measureParentContainer, + isComposerFocused, }; return ( diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js index 815a1c5619f5..62c29f3d418e 100644 --- a/src/pages/home/report/ReportActionCompose/suggestionProps.js +++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js @@ -24,6 +24,9 @@ const baseProps = { /** Meaures the parent container's position and dimensions. */ measureParentContainer: PropTypes.func.isRequired, + + /** Report composer focus state */ + isComposerFocused: PropTypes.bool, }; const implementationBaseProps = { diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index 0b6333e31ef8..57b51ef50519 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -18,7 +18,7 @@ import CONST from '../../../CONST'; import editedLabelStyles from '../../../styles/editedLabelStyles'; import UserDetailsTooltip from '../../../components/UserDetailsTooltip'; import avatarPropTypes from '../../../components/avatarPropTypes'; -import * as Browser from '../../../libs/Browser'; +import ZeroWidthView from '../../../components/ZeroWidthView'; const propTypes = { /** Users accountID */ @@ -90,24 +90,6 @@ const defaultProps = { }; function ReportActionItemFragment(props) { - /** - * Checks text element for presence of emoji as first character - * and insert Zero-Width character to avoid selection issue - * mentioned here https://github.com/Expensify/App/issues/29021 - * - * @param {String} text - * @param {Boolean} displayAsGroup - * @returns {ReactNode | null} Text component with zero width character - */ - - const checkForEmojiForSelection = (text, displayAsGroup) => { - const firstLetterIsEmoji = EmojiUtils.isFirstLetterEmoji(text); - if (firstLetterIsEmoji && !displayAsGroup && !Browser.isMobile()) { - return ; - } - return null; - }; - switch (props.fragment.type) { case 'COMMENT': { const {html, text} = props.fragment; @@ -139,7 +121,10 @@ function ReportActionItemFragment(props) { return ( - {checkForEmojiForSelection(text, props.displayAsGroup)} + diff --git a/src/pages/home/report/ReportDetailsShareCodePage.js b/src/pages/home/report/ReportDetailsShareCodePage.js index 62030f004bc6..7c22726ac82b 100644 --- a/src/pages/home/report/ReportDetailsShareCodePage.js +++ b/src/pages/home/report/ReportDetailsShareCodePage.js @@ -28,4 +28,4 @@ function ReportDetailsShareCodePage(props) { ReportDetailsShareCodePage.propTypes = propTypes; ReportDetailsShareCodePage.defaultProps = defaultProps; -export default withReportOrNotFound(ReportDetailsShareCodePage); +export default withReportOrNotFound()(ReportDetailsShareCodePage); diff --git a/src/pages/home/report/withReportOrNotFound.js b/src/pages/home/report/withReportOrNotFound.js index 5829ac7a6015..891e2e2418c6 100644 --- a/src/pages/home/report/withReportOrNotFound.js +++ b/src/pages/home/report/withReportOrNotFound.js @@ -9,7 +9,7 @@ import reportPropTypes from '../../reportPropTypes'; import FullscreenLoadingIndicator from '../../../components/FullscreenLoadingIndicator'; import * as ReportUtils from '../../../libs/ReportUtils'; -export default function (WrappedComponent) { +export default function (shouldRequireReportID = true) { const propTypes = { /** The HOC takes an optional ref as a prop and passes it as a ref to the wrapped component. * That way, if a ref is passed to a component wrapped in the HOC, the ref is a reference to the wrapped component, not the HOC. */ @@ -29,6 +29,14 @@ export default function (WrappedComponent) { }), ), + /** Route params */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** Report ID passed via route */ + reportID: PropTypes.string, + }), + }).isRequired, + /** Beta features list */ betas: PropTypes.arrayOf(PropTypes.string), @@ -44,67 +52,74 @@ export default function (WrappedComponent) { isLoadingReportData: true, }; - // eslint-disable-next-line rulesdir/no-negated-variables - function WithReportOrNotFound(props) { - const contentShown = React.useRef(false); - - const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID); + return (WrappedComponent) => { // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = _.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas); - - // If the content was shown but it's not anymore that means the report was deleted and we are probably navigating out of this screen. - // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition. - if (shouldShowNotFoundPage && contentShown.current) { - return null; + function WithReportOrNotFound(props) { + const contentShown = React.useRef(false); + + const isReportIdInRoute = !_.isUndefined(props.route.params.reportID); + + // If we should require reportID or we have a reportID in the route, we will check the reportID is valid or not + if (shouldRequireReportID || isReportIdInRoute) { + const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID); + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = _.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas); + + // If the content was shown but it's not anymore that means the report was deleted and we are probably navigating out of this screen. + // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition. + if (shouldShowNotFoundPage && contentShown.current) { + return null; + } + + if (shouldShowFullScreenLoadingIndicator) { + return ; + } + + if (shouldShowNotFoundPage) { + return ; + } + } + + if (!contentShown.current) { + contentShown.current = true; + } + + const rest = _.omit(props, ['forwardedRef']); + return ( + + ); } - if (shouldShowFullScreenLoadingIndicator) { - return ; - } + WithReportOrNotFound.propTypes = propTypes; + WithReportOrNotFound.defaultProps = defaultProps; + WithReportOrNotFound.displayName = `withReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - if (shouldShowNotFoundPage) { - return ; - } - - if (!contentShown.current) { - contentShown.current = true; - } - - const rest = _.omit(props, ['forwardedRef']); - return ( - ( + - ); - } - - WithReportOrNotFound.propTypes = propTypes; - WithReportOrNotFound.defaultProps = defaultProps; - WithReportOrNotFound.displayName = `withReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - - // eslint-disable-next-line rulesdir/no-negated-variables - const withReportOrNotFound = React.forwardRef((props, ref) => ( - - )); - - return withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, - }, - isLoadingReportData: { - key: ONYXKEYS.IS_LOADING_REPORT_DATA, - }, - betas: { - key: ONYXKEYS.BETAS, - }, - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - })(withReportOrNotFound); + )); + + return withOnyx({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, + }, + isLoadingReportData: { + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + })(withReportOrNotFound); + }; } diff --git a/src/pages/iou/MoneyRequestCategoryPage.js b/src/pages/iou/MoneyRequestCategoryPage.js index 5055c9f90f8a..7431a5deed77 100644 --- a/src/pages/iou/MoneyRequestCategoryPage.js +++ b/src/pages/iou/MoneyRequestCategoryPage.js @@ -12,6 +12,8 @@ import CategoryPicker from '../../components/CategoryPicker'; import ONYXKEYS from '../../ONYXKEYS'; import reportPropTypes from '../reportPropTypes'; import * as IOU from '../../libs/actions/IOU'; +import styles from '../../styles/styles'; +import Text from '../../components/Text'; import {iouPropTypes, iouDefaultProps} from './propTypes'; const propTypes = { @@ -70,7 +72,7 @@ function MoneyRequestCategoryPage({route, report, iou}) { title={translate('common.category')} onBackButtonPress={navigateBack} /> - + {translate('iou.categorySelection')} { @@ -89,7 +98,7 @@ function MoneyRequestSelectorPage(props) { testID={MoneyRequestSelectorPage.displayName} > {({safeAreaPaddingBottomStyle}) => ( - + `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, - }, - selectedTab: { - key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, - }, -})(MoneyRequestSelectorPage); +export default compose( + withReportOrNotFound(false), + withOnyx({ + selectedTab: { + key: `${ONYXKEYS.SELECTED_TAB}_${CONST.TAB.RECEIPT_TAB_ID}`, + }, + }), +)(MoneyRequestSelectorPage); diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js index a58f41e5e693..1c48a4f1a44a 100644 --- a/src/pages/iou/SplitBillDetailsPage.js +++ b/src/pages/iou/SplitBillDetailsPage.js @@ -145,6 +145,7 @@ function SplitBillDetailsPage(props) { reportActionID={reportAction.reportActionID} transactionID={props.transaction.transactionID} onConfirm={onConfirm} + isPolicyExpenseChat={ReportUtils.isPolicyExpenseChat(props.report)} /> )} diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index df10c5b4d609..8b697cde4880 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -127,14 +127,14 @@ function MoneyRequestConfirmPage(props) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (_.isEmpty(props.iou.participants) || (props.iou.amount === 0 && !props.iou.receiptPath && !isDistanceRequest) || shouldReset) { + if (_.isEmpty(props.iou.participants) || (props.iou.amount === 0 && !props.iou.receiptPath && !isDistanceRequest) || shouldReset || ReportUtils.isArchivedRoom(props.report)) { Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType.current, reportID.current), true); } return () => { prevMoneyRequestId.current = props.iou.id; }; - }, [props.iou.participants, props.iou.amount, props.iou.id, props.iou.receiptPath, isDistanceRequest]); + }, [props.iou.participants, props.iou.amount, props.iou.id, props.iou.receiptPath, isDistanceRequest, props.report]); const navigateBack = () => { let fallback; diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js index ae319f5a73bb..15a2c74d8a95 100644 --- a/src/pages/iou/steps/NewRequestAmountPage.js +++ b/src/pages/iou/steps/NewRequestAmountPage.js @@ -8,7 +8,6 @@ import _ from 'underscore'; import ONYXKEYS from '../../../ONYXKEYS'; import Navigation from '../../../libs/Navigation/Navigation'; import ROUTES from '../../../ROUTES'; -import * as ReportUtils from '../../../libs/ReportUtils'; import * as CurrencyUtils from '../../../libs/CurrencyUtils'; import reportPropTypes from '../../reportPropTypes'; import * as IOU from '../../../libs/actions/IOU'; @@ -83,14 +82,6 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { }, []), ); - // Check and dismiss modal - useEffect(() => { - if (!ReportUtils.shouldDisableWriteActions(report)) { - return; - } - Navigation.dismissModal(reportID); - }, [report, reportID]); - // Because we use Onyx to store IOU info, when we try to make two different money requests from different tabs, // it can result in an IOU sent with improper values. In such cases we want to reset the flow and redirect the user to the first step of the IOU. useEffect(() => { diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js index cce43117d4f2..480c425a9094 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js @@ -122,7 +122,7 @@ function NewContactMethodPage(props) { ref={(el) => (loginInputRef.current = el)} inputID="phoneOrEmail" autoCapitalize="none" - returnKeyType="done" + returnKeyType="go" maxLength={CONST.LOGIN_CHARACTER_LIMIT} /> diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js index 64e6bdfb4b5b..43e346150ca8 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.js +++ b/src/pages/settings/Report/NotificationPreferencePage.js @@ -56,4 +56,4 @@ function NotificationPreferencePage(props) { NotificationPreferencePage.displayName = 'NotificationPreferencePage'; NotificationPreferencePage.propTypes = propTypes; -export default compose(withLocalize, withReportOrNotFound)(NotificationPreferencePage); +export default compose(withLocalize, withReportOrNotFound())(NotificationPreferencePage); diff --git a/src/pages/settings/Report/ReportSettingsPage.js b/src/pages/settings/Report/ReportSettingsPage.js index 9ec6eb071de2..fb88cbd59f25 100644 --- a/src/pages/settings/Report/ReportSettingsPage.js +++ b/src/pages/settings/Report/ReportSettingsPage.js @@ -209,7 +209,7 @@ ReportSettingsPage.propTypes = propTypes; ReportSettingsPage.defaultProps = defaultProps; ReportSettingsPage.displayName = 'ReportSettingsPage'; export default compose( - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ policies: { key: ONYXKEYS.COLLECTION.POLICY, diff --git a/src/pages/settings/Report/RoomNamePage.js b/src/pages/settings/Report/RoomNamePage.js index 985d83e7fd95..4ce997533378 100644 --- a/src/pages/settings/Report/RoomNamePage.js +++ b/src/pages/settings/Report/RoomNamePage.js @@ -118,7 +118,7 @@ RoomNamePage.displayName = 'RoomNamePage'; export default compose( withLocalize, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ reports: { key: ONYXKEYS.COLLECTION.REPORT, diff --git a/src/pages/settings/Report/WriteCapabilityPage.js b/src/pages/settings/Report/WriteCapabilityPage.js index 1558d98a830a..174cc57d8d18 100644 --- a/src/pages/settings/Report/WriteCapabilityPage.js +++ b/src/pages/settings/Report/WriteCapabilityPage.js @@ -67,7 +67,7 @@ WriteCapabilityPage.defaultProps = defaultProps; export default compose( withLocalize, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, diff --git a/src/pages/settings/Wallet/WalletEmptyState.js b/src/pages/settings/Wallet/WalletEmptyState.js index adfd2cf49cee..f54716e3110a 100644 --- a/src/pages/settings/Wallet/WalletEmptyState.js +++ b/src/pages/settings/Wallet/WalletEmptyState.js @@ -9,6 +9,7 @@ import ROUTES from '../../../ROUTES'; import * as Illustrations from '../../../components/Icon/Illustrations'; import FeatureList from '../../../components/FeatureList'; import themeColors from '../../../styles/themes/default'; +import SCREENS from '../../../SCREENS'; const propTypes = { /** The function that is called when a menu item is pressed */ @@ -34,7 +35,7 @@ function WalletEmptyState({onAddPaymentMethod}) { const {translate} = useLocalize(); return ( Navigation.goBack(ROUTES.SETTINGS)} title={translate('common.wallet')} diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.js b/src/pages/settings/Wallet/WalletPage/WalletPage.js index 37bb49952984..d1a2e4780f0d 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.js +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.js @@ -6,7 +6,7 @@ import PaymentMethodList from '../PaymentMethodList'; import ROUTES from '../../../../ROUTES'; import HeaderWithBackButton from '../../../../components/HeaderWithBackButton'; import ScreenWrapper from '../../../../components/ScreenWrapper'; -import Navigation, {navigationRef} from '../../../../libs/Navigation/Navigation'; +import Navigation from '../../../../libs/Navigation/Navigation'; import styles from '../../../../styles/styles'; import compose from '../../../../libs/compose'; import * as BankAccounts from '../../../../libs/actions/BankAccounts'; @@ -61,7 +61,7 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen const [showConfirmDeleteContent, setShowConfirmDeleteContent] = useState(false); const hasBankAccount = !_.isEmpty(bankAccountList) || !_.isEmpty(fundList); - const hasWallet = userWallet.walletLinkedAccountID > 0; + const hasWallet = !_.isEmpty(userWallet); const hasActivatedWallet = _.contains([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM], userWallet.tierName); const hasAssignedCard = !_.isEmpty(cardList); const shouldShowEmptyState = !hasBankAccount && !hasWallet && !hasAssignedCard; @@ -299,13 +299,6 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen } }, [hideDefaultDeleteMenu, paymentMethod.methodID, paymentMethod.selectedPaymentMethodType, bankAccountList, fundList, shouldShowDefaultDeleteMenu]); - useEffect(() => { - if (!shouldShowEmptyState) { - return; - } - navigationRef.setParams({backgroundColor: themeColors.walletPageBG}); - }, [shouldShowEmptyState]); - const shouldShowMakeDefaultButton = !paymentMethod.isSelectedPaymentMethodDefault && Permissions.canUseWallet(betas) && diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index 3576f92be31f..8adedde6d546 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -213,6 +213,7 @@ function LoginForm(props) { accessibilityLabel={translate('loginForm.phoneOrEmail')} accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} value={login} + returnKeyType="go" autoCompleteType="username" textContentType="username" nativeID="username" diff --git a/src/pages/tasks/TaskDescriptionPage.js b/src/pages/tasks/TaskDescriptionPage.js index be2cdad03fe6..01ed7e55f8a0 100644 --- a/src/pages/tasks/TaskDescriptionPage.js +++ b/src/pages/tasks/TaskDescriptionPage.js @@ -130,7 +130,7 @@ TaskDescriptionPage.displayName = 'TaskDescriptionPage'; export default compose( withLocalize, withCurrentUserPersonalDetails, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ session: { key: ONYXKEYS.SESSION, diff --git a/src/pages/tasks/TaskTitlePage.js b/src/pages/tasks/TaskTitlePage.js index fca90a5fa904..32cd6c3d2cc3 100644 --- a/src/pages/tasks/TaskTitlePage.js +++ b/src/pages/tasks/TaskTitlePage.js @@ -125,7 +125,7 @@ TaskTitlePage.displayName = 'TaskTitlePage'; export default compose( withLocalize, withCurrentUserPersonalDetails, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ session: { key: ONYXKEYS.SESSION, diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts index 62da2bf3be4b..48100b2efb60 100644 --- a/src/styles/StyleUtils.ts +++ b/src/styles/StyleUtils.ts @@ -882,17 +882,19 @@ function getErrorPageContainerStyle(safeAreaPaddingBottom = 0): ViewStyle { /** * Gets the correct size for the empty state background image based on screen dimensions */ -function getReportWelcomeBackgroundImageStyle(isSmallScreenWidth: boolean): ViewStyle { +function getReportWelcomeBackgroundImageStyle(isSmallScreenWidth: boolean, isMoneyReport = false): ViewStyle { + const emptyStateBackground = isMoneyReport ? CONST.EMPTY_STATE_BACKGROUND.MONEY_REPORT : CONST.EMPTY_STATE_BACKGROUND; + if (isSmallScreenWidth) { return { - height: CONST.EMPTY_STATE_BACKGROUND.SMALL_SCREEN.IMAGE_HEIGHT, + height: emptyStateBackground.SMALL_SCREEN.IMAGE_HEIGHT, width: '200%', position: 'absolute', }; } return { - height: CONST.EMPTY_STATE_BACKGROUND.WIDE_SCREEN.IMAGE_HEIGHT, + height: emptyStateBackground.WIDE_SCREEN.IMAGE_HEIGHT, width: '100%', position: 'absolute', }; @@ -901,15 +903,16 @@ function getReportWelcomeBackgroundImageStyle(isSmallScreenWidth: boolean): View /** * Gets the correct top margin size for the chat welcome message based on screen dimensions */ -function getReportWelcomeTopMarginStyle(isSmallScreenWidth: boolean): ViewStyle { +function getReportWelcomeTopMarginStyle(isSmallScreenWidth: boolean, isMoneyReport = false): ViewStyle { + const emptyStateBackground = isMoneyReport ? CONST.EMPTY_STATE_BACKGROUND.MONEY_REPORT : CONST.EMPTY_STATE_BACKGROUND; if (isSmallScreenWidth) { return { - marginTop: CONST.EMPTY_STATE_BACKGROUND.SMALL_SCREEN.VIEW_HEIGHT, + marginTop: emptyStateBackground.SMALL_SCREEN.VIEW_HEIGHT, }; } return { - marginTop: CONST.EMPTY_STATE_BACKGROUND.WIDE_SCREEN.VIEW_HEIGHT, + marginTop: emptyStateBackground.WIDE_SCREEN.VIEW_HEIGHT, }; } @@ -934,17 +937,18 @@ function getLineHeightStyle(lineHeight: number): TextStyle { /** * Gets the correct size for the empty state container based on screen dimensions */ -function getReportWelcomeContainerStyle(isSmallScreenWidth: boolean): ViewStyle { +function getReportWelcomeContainerStyle(isSmallScreenWidth: boolean, isMoneyReport = false): ViewStyle { + const emptyStateBackground = isMoneyReport ? CONST.EMPTY_STATE_BACKGROUND.MONEY_REPORT : CONST.EMPTY_STATE_BACKGROUND; if (isSmallScreenWidth) { return { - minHeight: CONST.EMPTY_STATE_BACKGROUND.SMALL_SCREEN.CONTAINER_MINHEIGHT, + minHeight: emptyStateBackground.SMALL_SCREEN.CONTAINER_MINHEIGHT, display: 'flex', justifyContent: 'space-between', }; } return { - minHeight: CONST.EMPTY_STATE_BACKGROUND.WIDE_SCREEN.CONTAINER_MINHEIGHT, + minHeight: emptyStateBackground.WIDE_SCREEN.CONTAINER_MINHEIGHT, display: 'flex', justifyContent: 'space-between', }; diff --git a/src/styles/themes/default.ts b/src/styles/themes/default.ts index f8be30a9d881..623f4e543fee 100644 --- a/src/styles/themes/default.ts +++ b/src/styles/themes/default.ts @@ -89,6 +89,7 @@ const darkTheme = { [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, + [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, [SCREENS.SETTINGS.SECURITY]: colors.ice500, [SCREENS.SETTINGS.STATUS]: colors.green700, [SCREENS.SETTINGS.ROOT]: colors.darkHighlightBackground, diff --git a/src/styles/themes/light.ts b/src/styles/themes/light.ts index 01ceb6cf8c77..44d8706e25b7 100644 --- a/src/styles/themes/light.ts +++ b/src/styles/themes/light.ts @@ -89,6 +89,7 @@ const lightTheme = { [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, + [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, [SCREENS.SETTINGS.SECURITY]: colors.ice500, [SCREENS.SETTINGS.STATUS]: colors.green700, [SCREENS.SETTINGS.ROOT]: colors.lightHighlightBackground, diff --git a/tests/perf-test/ReportActionsList.perf-test.js b/tests/perf-test/ReportActionsList.perf-test.js new file mode 100644 index 000000000000..4d83cc435636 --- /dev/null +++ b/tests/perf-test/ReportActionsList.perf-test.js @@ -0,0 +1,188 @@ +import {measurePerformance} from 'reassure'; +import Onyx from 'react-native-onyx'; +import {screen, fireEvent} from '@testing-library/react-native'; +import ReportActionsList from '../../src/pages/home/report/ReportActionsList'; +import ComposeProviders from '../../src/components/ComposeProviders'; +import OnyxProvider from '../../src/components/OnyxProvider'; +import {ReportAttachmentsProvider} from '../../src/pages/home/report/ReportAttachmentsContext'; +import {WindowDimensionsProvider} from '../../src/components/withWindowDimensions'; +import {LocaleContextProvider} from '../../src/components/LocaleContextProvider'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; +import PusherHelper from '../utils/PusherHelper'; +import variables from '../../src/styles/variables'; +import {ActionListContext, ReactionListContext} from '../../src/pages/home/ReportScreenContext'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import * as Localize from '../../src/libs/Localize'; + +jest.setTimeout(60000); + +const mockedNavigate = jest.fn(); + +jest.mock('../../src/components/withNavigationFocus', () => (Component) => (props) => ( + +)); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useRoute: () => mockedNavigate, + }; +}); + +beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + registerStorageEventListener: () => {}, + }), +); + +afterAll(() => { + jest.clearAllMocks(); +}); + +const mockOnLayout = jest.fn(); +const mockOnScroll = jest.fn(); +const mockLoadMoreChats = jest.fn(); +const mockRef = {current: null}; + +// Initialize the network key for OfflineWithFeedback +beforeEach(() => { + PusherHelper.setup(); + wrapOnyxWithWaitForBatchedUpdates(Onyx); + return Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); +}); + +// Clear out Onyx after each test so that each test starts with a clean slate +afterEach(() => { + Onyx.clear(); + PusherHelper.teardown(); +}); + +const getFakeReportAction = (index) => ({ + actionName: 'ADDCOMMENT', + actorAccountID: index, + automatic: false, + avatar: '', + created: '2023-09-12 16:27:35.124', + isAttachment: true, + isFirstItem: false, + lastModified: '2021-07-14T15:00:00Z', + message: [ + { + html: 'hey', + isDelatedParentAction: false, + isEdited: false, + reactions: [], + text: 'test', + type: 'TEXT', + whisperedTo: [], + }, + ], + originalMessage: { + html: 'hey', + lastModified: '2021-07-14T15:00:00Z', + }, + pendingAction: null, + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'email@test.com', + }, + ], + previousReportActionID: '1', + reportActionID: index.toString(), + reportActionTimestamp: 1696243169753, + sequenceNumber: 2, + shouldShow: true, + timestamp: 1696243169, + whisperedToAccountIDs: [], +}); + +const getMockedSortedReportActions = (length = 100) => Array.from({length}, (__, i) => getFakeReportAction(i)); + +const currentUserAccountID = 5; + +function ReportActionsListWrapper() { + return ( + + + + + + + + ); +} + +test('should render ReportActionsList with 500 reportActions stored', () => { + const scenario = async () => { + await screen.findByTestId('report-actions-list'); + const hintText = Localize.translateLocal('accessibilityHints.chatMessage'); + // Ensure that the list of items is rendered + await screen.findAllByLabelText(hintText); + }; + + return waitForBatchedUpdates() + .then(() => + Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + }), + ) + .then(() => measurePerformance(, {scenario})); +}); + +test('should scroll and click some of the reports', () => { + const eventData = { + nativeEvent: { + contentOffset: { + y: variables.optionRowHeight * 5, + }, + contentSize: { + // Dimensions of the scrollable content + height: variables.optionRowHeight * 10, + width: 100, + }, + layoutMeasurement: { + // Dimensions of the device + height: variables.optionRowHeight * 5, + width: 100, + }, + }, + }; + + const scenario = async () => { + const reportActionsList = await screen.findByTestId('report-actions-list'); + expect(reportActionsList).toBeDefined(); + + fireEvent.scroll(reportActionsList, eventData); + + const hintText = Localize.translateLocal('accessibilityHints.chatMessage'); + const reportItems = await screen.findAllByLabelText(hintText); + + fireEvent.press(reportItems[0], 'onLongPress'); + }; + + return waitForBatchedUpdates() + .then(() => + Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + }), + ) + .then(() => measurePerformance(, {scenario})); +});