diff --git a/android/app/build.gradle b/android/app/build.gradle index 7364d9ebc6d0..87f4d20d79fe 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043302 - versionName "1.4.33-2" + versionCode 1001043304 + versionName "1.4.33-4" } flavorDimensions "default" diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index ffec5f20254c..d87f9f889090 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -161,7 +161,7 @@ function validate(values) { errors = ErrorUtils.addErrorMessage(errors, 'firstName', 'personalDetails.error.hasInvalidCharacter'); } - if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_FIRST_NAMES)) { + if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { errors = ErrorUtils.addErrorMessage(errors, 'firstName', 'personalDetails.error.containsReservedWord'); } diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index 0887e90dcc8b..33ef4ebcf0a8 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -118,7 +118,3 @@ platforms: 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. - - href: workspace-and-domain-settings - title: Workspace & Domain Settings - icon: /assets/images/shield.svg - description: Discover how to set up and manage your workspace, define user permissions, and implement domain-level rules. diff --git a/docs/articles/new-expensify/workspace-and-domain-settings/Domain-Settings-Overview.md b/docs/articles/new-expensify/workspace-and-domain-settings/Domain-Settings-Overview.md deleted file mode 100644 index 730d696d97f4..000000000000 --- a/docs/articles/new-expensify/workspace-and-domain-settings/Domain-Settings-Overview.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: Domains -description: Want to gain greater control over your company settings in Expensify? Read on to find out more about our Domains feature and how it can help you save time and effort when managing your company expenses. ---- -
-# Overview -Domains is a feature in Expensify that allows admins to have more nuanced control over a specific Expensify activity, as well as providing a bird’s eye view of company card expenditure. Think of it as your command center for things like managing user account access, enforcing stricter Workspace rules for certain groups, or issuing cards and reconciling statements. -There are several settings within Domains that you can configure so that you have more control and visibility into your organization’s settings. Those features are: -- Company Cards -- Domain Admins -- Domain Members - - Two-Factor Authentication -- Domain Groups - - Domain Group Settings -- Reporting Tools -- SAML - -There are two ways to use Domains – as an unverified domain or a verified domain. An unverified domain allows you to import Company Cards and manage them, whereas a verified domain allows you to do that in addition to: -1. Receive vendor bills in Expensify -2. Fine-tune user restrictions using domain Groups -3. Configure SAML SSO for easier login to Expensify -4. Set vacation delegates for your domain members -5. Use consolidated domain billing - -# How to claim a domain -To use the domains feature with an unverified domain, you’ll need to claim the domain first. -To claim a domain, you need to be a Workspace Admin with a company email address. This allows you to manage company bills, company cards, and reconciliation. Claiming requires an email matching your company's domain. -1. Create an Expensify account -2. Set up an expense Workspace -3. Go to **Settings > _Domains_**. -Whichever member runs through those steps will automatically be made a Domain Admin. - - -# How to verify a domain -To use the domains feature with a verified domain, you’ll want to go through the steps of verifying it. - -To verify domain ownership, follow these steps: -1. Log in to your DNS service provider, which could be your Domain Name Registrar like NameCheap or GoDaddy, a dedicated DNS service provider like DNSMadeEasy or Amazon Route53, or managed internally by your company's IT department. -2. Find the page for editing DNS records for expensify.com. This might be labeled as DNS Management or Zone File Editor. -3. Add a new TXT record and set the value as: **532F6180D8** -4. Save your changes -5. Click the Verify button to confirm domain ownership - -After successful verification, you can remove the TXT DNS record. Please note that an email will be sent to all Expensify users on the domain to inform them that their accounts will be under Domain Control after verification. - -**Tips:** -Not sure how to do this? Check the below guides from some of the most popular hosts on the web: -[123-reg.co.uk](https://www.123-reg.co.uk/) -[One.com](https://www.one.com/en/) -[Wix.com](https://www.wix.com/) -Google/GSuite -[Godaddy](https://www.godaddy.com/) -When creating the TXT record, input only the code and no other values or information. -You can always confirm if you added the TXT code correctly here: https://viewdns.info/dnsrecord/?domain=[enterdomainhere] - -# Domain settings - -## Domain Admins -Domain Admins have full authority over domain settings. They can modify member group names and rules, link or modify Company Cards, and add or remove domain members and other admins. - -### Adding a Domain Admin -1. Head to **Settings > Domains > [Domain Name] > Domain Admins** -2. In the "Email or Phone" field, type in the email address of the person you want to make a Domain Admin (this can be any email not specifically tied to the domain) -3. Click "Add Admin" - -### Removing a Domain Admin: -1. If you're already a Domain Admin, go to **Settings > Domains > [Domain Name] > Domain Admins** -2. Locate the list of Domain Admins and find the one you want to remove -3. Next to the Domain Admin's name, click the red trash can icon. This will remove that person from the Domain Admin role - -## Domain Members -A domain member is a user associated with a specific domain (usually a company or another group) in Expensify and typically managed by a Domain Admin. This is also where you can enable Two-Factor authentication for your domain. - -### Adding users to the domain -When a Domain Admin adds a user to the domain, that will create a new Expensify account for that user, and they'll receive invitations to set up their account. Users can also join a verified domain by creating their own account, as long as they have an email address associated with that domain (e.g. yourname@yourcompany.com). Once they have verified the account, all Domain Admins will be notified, and the employee will be added to the Default Group. -**Important Note:** If someone who isn't a Domain Admin invites a user to a Workspace before they're invited to the domain, their account will be created, but in a closed state. A closed state means that the account cannot be used until it has been validated. Once the Domain Admin has invited the user, the user will receive a magic link to verify their account, sign in, and open the account completely. - -### How to add users -1. In your web account, go to **Settings > Domains > [Domain Name] > Domain Admins** -2. In the email field, enter the user you want to invite. This will create their Expensify account and send them an invitation - -### Removing users from the Domain -Removing a user means taking them out of your domain and closing their Expensify account completely if they don't have another login. Be cautious because closing an account is permanent and deletes any unsubmitted or processing reports. - -### How to remove users -In your web account, go to **Settings > Domains > [Domain Name] > Domain Admins** -Check the box next to the employee's name you want to remove, then click “Close Accounts”. - -### Important notes about closing accounts through Domain settings: -If a user has a Secondary Login linked to their Expensify account, they can still access their account after it's closed in the domain. This is helpful for accessing financial data, like tax-related receipts. -Closing an account through the domain permanently removes any unsubmitted receipts/reports. Make sure to approve or reimburse all employee reports before closing an account. -If an employee doesn't have a Secondary Login, they'll be automatically removed from the group Workspace. If they have a Secondary Login, it will continue to be associated with the group Workspace. - -## Domain Groups -Domain Groups can be accessed if you have verified your domain. Groups are used to set rules or permissions for groups of users so you can enforce multiple different expense workspaces and rules. If you are a Domain Admin, you can create and edit Domain Groups under **Settings > Domains > _Domain Name_ > Groups**. - -### Creating Domain Groups -1. In your Expensify account on the web, navigate to **Settings > Domains > _Domain Name_ > Groups** -2. Select “Create Group” to create the group. This will allow you to name the Group, as well as configure permissions that will apply to members of the Group. - -### Adding members to a Domain Group -1. In your Expensify account on the web, navigate to **Settings > Domains > [Domain Name] > Domain Members** -2. Select the checkbox next to the domain members you wish to add to the Domain Group -3. Select “Add to Group” to select the Group you wish to add them to - -### Editing Domain Groups -1. In your Expensify account on the web, navigate to **Settings > Domains > _Domain Name_ > Groups** -2. Next to the Group you wish to edit, select “Edit” -3. This will open the Edit Permission Group pane, where you can edit the rules and permissions for that group -4. Make your edits and click “Save” - -## Domain Group settings -These are the settings that can be customized for each group you have created. Typically, companies use two groups (Employees and Managers) and enforce stricter rules for Employees. The settings are: -- Strict Workspace Enforcement: When enabled, all Workspace rules must be followed for a report to be submitted. If a rule is violated, the report can't be submitted until the issue is fixed. Employees can't bypass this by dismissing notifications. -- Login Restrictions: Enabling this prevents users from using non-company email addresses as their primary login. Secondary logins are still allowed. -- Workspace Creation and Removal Restrictions: This feature stops users from creating new group workspaces or unsubscribing from existing workspaces. Admins who need these abilities should be in a separate group with this restriction turned off. -- Preferred Workspace: When enabled, group members can only create reports under one designated Workspace. They can move a report to a different Workspace or their personal one later if needed. This helps keep personal and company expenses separate. If a company card uses a specific Workspace, this setting overrides it for more control over company card expenses. -- Setting a Preferred Workspace: If Preferred Workspace is on, you can choose a default group Workspace for all Group Members. - -## SAML -To enable SAML SSO in Expensify you will first need to claim and verify your domain. Once you have a verified domain, you can access SAML SSO by navigating to **Settings > Domains > _Domain Name_ > SAML** - -## Enable Two-Factor Authentication (2FA) -1. As a Domain Admin, head to: **Settings > Domains > _Your Domain Name_ > Domain Members** -2. Turn on Two Factor Authentication by toggling it to ENABLED -3. Any Domain members that do not have two-factor authentication enabled will be asked to set it up on their Home page when they next log in, and won't be able to use Expensify until they do. -4. To turn it off, simply toggle it off and refresh the page. - -**Tips:** -- When using SAML, two-factor authentication cannot be required. -- For disputing digital Expensify Card purchases, two-factor authentication must be enabled. -- It might take up to 2 hours for domain-level enforcement to take effect, and users will be prompted to configure their individual 2FA settings on their next login to Expensify. - -{% include faq-begin.md %} - -## How many domains can I have? -You can manage multiple domains by adding them through **Settings > Domains > New Domain**. However, to verify additional domains, you must be a Workspace Admin on a Control Workspace. Keep in mind that the Collect plan allows verification for just one domain. - -## What’s the difference between claiming a domain and verifying a domain? -Claiming a domain is limited to users with matching email domains, and allows Workspace Admins with a company email to manage bills, company cards, and reconciliation. Verifying a domain offers extra features and security. - -{% include faq-end.md %} -
\ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 2d64e813f68f..2cc33b9217a5 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.33.2 + 1.4.33.4 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index ad5974724031..6b12d614dd7a 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.33.2 + 1.4.33.4 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 0e2560ae22d2..f30fce7d326b 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.33 CFBundleVersion - 1.4.33.2 + 1.4.33.4 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 68638e12116a..8bba7075ee2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.33-2", + "version": "1.4.33-4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.33-2", + "version": "1.4.33-4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4493b63b51ec..0c05438769d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.33-2", + "version": "1.4.33-4", "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 27eb04b66ecc..1ccdfd9a82a8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -92,7 +92,7 @@ const CONST = { DISPLAY_NAME: { MAX_LENGTH: 50, - RESERVED_FIRST_NAMES: ['Expensify', 'Concierge'], + RESERVED_NAMES: ['Expensify', 'Concierge'], }, LEGAL_NAME: { @@ -566,6 +566,7 @@ const CONST = { INDIVIDUAL_BUDGET_NOTIFICATION: 'POLICYCHANGELOG_INDIVIDUAL_BUDGET_NOTIFICATION', INVITE_TO_ROOM: 'POLICYCHANGELOG_INVITETOROOM', REMOVE_FROM_ROOM: 'POLICYCHANGELOG_REMOVEFROMROOM', + LEAVE_ROOM: 'POLICYCHANGELOG_LEAVEROOM', REPLACE_CATEGORIES: 'POLICYCHANGELOG_REPLACE_CATEGORIES', SET_AUTOREIMBURSEMENT: 'POLICYCHANGELOG_SET_AUTOREIMBURSEMENT', SET_AUTO_JOIN: 'POLICYCHANGELOG_SET_AUTO_JOIN', @@ -608,6 +609,7 @@ const CONST = { ROOMCHANGELOG: { INVITE_TO_ROOM: 'INVITETOROOM', REMOVE_FROM_ROOM: 'REMOVEFROMROOM', + LEAVE_ROOM: 'LEAVEROOM', }, }, THREAD_DISABLED: ['CREATED'], diff --git a/src/ROUTES.ts b/src/ROUTES.ts index deabdc0ac853..9c4375b84ab6 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -267,6 +267,10 @@ const ROUTES = { route: ':iouType/new/participants/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}` as const, }, + MONEY_REQUEST_CONFIRMATION: { + route: ':iouType/new/confirmation/:reportID?', + getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}` as const, + }, MONEY_REQUEST_DATE: { route: ':iouType/new/date/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 5a8922ee01c3..2bf40caede57 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -141,6 +141,7 @@ const SCREENS = { ROOT: 'Money_Request', AMOUNT: 'Money_Request_Amount', PARTICIPANTS: 'Money_Request_Participants', + CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', DATE: 'Money_Request_Date', DESCRIPTION: 'Money_Request_Description', diff --git a/src/components/Hoverable/ActiveHoverable.tsx b/src/components/Hoverable/ActiveHoverable.tsx index 028fdd30cf35..4bc56d35af45 100644 --- a/src/components/Hoverable/ActiveHoverable.tsx +++ b/src/components/Hoverable/ActiveHoverable.tsx @@ -14,6 +14,7 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, children}: const elementRef = useRef(null); const isScrollingRef = useRef(false); const isHoveredRef = useRef(false); + const isVisibiltyHidden = useRef(false); const updateIsHovered = useCallback( (hovered: boolean) => { @@ -75,7 +76,14 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, children}: }, [isHovered, elementRef]); useEffect(() => { - const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false); + const unsetHoveredWhenDocumentIsHidden = () => { + if (document.visibilityState !== 'hidden') { + return; + } + + isVisibiltyHidden.current = true; + setIsHovered(false); + }; document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); @@ -86,9 +94,11 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, children}: const childOnMouseEnter = child.props.onMouseEnter; const childOnMouseLeave = child.props.onMouseLeave; + const childOnMouseMove = child.props.onMouseMove; const hoverAndForwardOnMouseEnter = useCallback( (e: MouseEvent) => { + isVisibiltyHidden.current = false; updateIsHovered(true); childOnMouseEnter?.(e); }, @@ -116,11 +126,21 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, children}: [child.props], ); + const handleAndForwardOnMouseMove = useCallback( + (e: MouseEvent) => { + isVisibiltyHidden.current = false; + updateIsHovered(true); + childOnMouseMove?.(e); + }, + [updateIsHovered, childOnMouseMove], + ); + return cloneElement(child, { ref: mergeRefs(elementRef, outerRef, child.ref), onMouseEnter: hoverAndForwardOnMouseEnter, onMouseLeave: unhoverAndForwardOnMouseLeave, onBlur: unhoverAndForwardOnBlur, + ...(isVisibiltyHidden.current ? {onMouseMove: handleAndForwardOnMouseMove} : {}), }); } diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index f8d75b01c657..b2fece085f57 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -2,7 +2,7 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx/lib/types'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 815b80aaa50e..f3f609590101 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -353,11 +353,6 @@ function BaseSelectionList( return; } - // scroll is unnecessary if multiple options cannot be selected - if (!canSelectMultiple) { - return; - } - // set the focus on the first item when the sections list is changed if (sections.length > 0) { updateAndScrollToFocusedIndex(0); diff --git a/src/languages/en.ts b/src/languages/en.ts index 98d00089637a..58af1c6638c4 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1141,7 +1141,7 @@ export default { }, personalDetails: { error: { - containsReservedWord: 'First name cannot contain the words Expensify or Concierge', + containsReservedWord: 'Name cannot contain the words Expensify or Concierge', hasInvalidCharacter: 'Name cannot contain a comma or semicolon', }, }, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 3a843e400409..c9325206e5b2 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -93,6 +93,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/MoneyRequestSelectorPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.AMOUNT]: () => require('../../../pages/iou/steps/NewRequestAmountPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.PARTICIPANTS]: () => require('../../../pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage').default as React.ComponentType, + [SCREENS.MONEY_REQUEST.CONFIRMATION]: () => require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, [SCREENS.MONEY_REQUEST.DATE]: () => require('../../../pages/iou/MoneyRequestDatePage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.DESCRIPTION]: () => require('../../../pages/iou/MoneyRequestDescriptionPage').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig.ts b/src/libs/Navigation/linkingConfig.ts index d4e04d5402e2..5df2bcf0e57b 100644 --- a/src/libs/Navigation/linkingConfig.ts +++ b/src/libs/Navigation/linkingConfig.ts @@ -428,6 +428,7 @@ const linkingConfig: LinkingOptions = { [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.route, [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route, [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route, + [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route, [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route, [SCREENS.MONEY_REQUEST.DESCRIPTION]: ROUTES.MONEY_REQUEST_DESCRIPTION.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index b4a77f96cc74..2371c764f42a 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -185,6 +185,10 @@ type MoneyRequestNavigatorParamList = { iouType: string; reportID: string; }; + [SCREENS.MONEY_REQUEST.CONFIRMATION]: { + iouType: string; + reportID: string; + }; [SCREENS.MONEY_REQUEST.CURRENCY]: { iouType: string; reportID: string; diff --git a/src/pages/ReportWelcomeMessagePage.js b/src/pages/ReportWelcomeMessagePage.tsx similarity index 62% rename from src/pages/ReportWelcomeMessagePage.js rename to src/pages/ReportWelcomeMessagePage.tsx index ae8a4635a98e..53f3e7fcadda 100644 --- a/src/pages/ReportWelcomeMessagePage.js +++ b/src/pages/ReportWelcomeMessagePage.tsx @@ -1,64 +1,58 @@ import {useFocusEffect} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; -import PropTypes from 'prop-types'; import React, {useCallback, useRef, useState} from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; +import type {ReportWelcomeMessageNavigatorParamList} from '@navigation/types'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Policy} from '@src/types/onyx'; +import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; -import reportPropTypes from './reportPropTypes'; -import {policyDefaultProps, policyPropTypes} from './workspace/withPolicy'; -const propTypes = { - ...withLocalizePropTypes, - ...policyPropTypes, - - /** The report currently being looked at */ - report: reportPropTypes.isRequired, - - /** Route params */ - route: PropTypes.shape({ - params: PropTypes.shape({ - /** Report ID passed via route r/:reportID/welcomeMessage */ - reportID: PropTypes.string, - }), - }).isRequired, +type ReportWelcomeMessagePageOnyxProps = { + /** The policy object for the current route */ + policy: OnyxEntry; }; -const defaultProps = { - ...policyDefaultProps, -}; +type ReportWelcomeMessagePageProps = ReportWelcomeMessagePageOnyxProps & + WithReportOrNotFoundProps & + StackScreenProps; -function ReportWelcomeMessagePage(props) { +function ReportWelcomeMessagePage({report, policy}: ReportWelcomeMessagePageProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); + const parser = new ExpensiMark(); - const [welcomeMessage, setWelcomeMessage] = useState(() => parser.htmlToMarkdown(props.report.welcomeMessage)); - const welcomeMessageInputRef = useRef(null); - const focusTimeoutRef = useRef(null); + const [welcomeMessage, setWelcomeMessage] = useState(() => parser.htmlToMarkdown(report?.welcomeMessage ?? '')); + const welcomeMessageInputRef = useRef(null); + const focusTimeoutRef = useRef(null); - const handleWelcomeMessageChange = useCallback((value) => { + const handleWelcomeMessageChange = useCallback((value: string) => { setWelcomeMessage(value); }, []); const submitForm = useCallback(() => { - Report.updateWelcomeMessage(props.report.reportID, props.report.welcomeMessage, welcomeMessage.trim()); - }, [props.report.reportID, props.report.welcomeMessage, welcomeMessage]); + Report.updateWelcomeMessage(report?.reportID ?? '', report?.welcomeMessage ?? '', welcomeMessage.trim()); + }, [report?.reportID, report?.welcomeMessage, welcomeMessage]); useFocusEffect( useCallback(() => { @@ -82,33 +76,33 @@ function ReportWelcomeMessagePage(props) { includeSafeAreaPaddingBottom={false} testID={ReportWelcomeMessagePage.displayName} > - + Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(props.report.reportID))} + title={translate('welcomeMessagePage.welcomeMessage')} + onBackButtonPress={() => Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report?.reportID ?? ''))} /> - {props.translate('welcomeMessagePage.explainerText')} + {translate('welcomeMessagePage.explainerText')} { - if (!el) { + ref={(element: AnimatedTextInputRef) => { + if (!element) { return; } - welcomeMessageInputRef.current = el; + welcomeMessageInputRef.current = element; updateMultilineInputRange(welcomeMessageInputRef.current); }} value={welcomeMessage} @@ -124,15 +118,11 @@ function ReportWelcomeMessagePage(props) { } ReportWelcomeMessagePage.displayName = 'ReportWelcomeMessagePage'; -ReportWelcomeMessagePage.propTypes = propTypes; -ReportWelcomeMessagePage.defaultProps = defaultProps; -export default compose( - withLocalize, - withReportOrNotFound(), - withOnyx({ +export default withReportOrNotFound()( + withOnyx({ policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, }, - }), -)(ReportWelcomeMessagePage); + })(ReportWelcomeMessagePage), +); diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx index 7613bafeacdc..5c10cc6fc7ad 100644 --- a/src/pages/home/report/withReportOrNotFound.tsx +++ b/src/pages/home/report/withReportOrNotFound.tsx @@ -1,15 +1,17 @@ /* eslint-disable rulesdir/no-negated-variables */ import type {RouteProp} from '@react-navigation/native'; import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; -import React from 'react'; +import React, {useEffect} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import * as ReportUtils from '@libs/ReportUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; type OnyxProps = { /** The report currently being looked at */ @@ -22,21 +24,33 @@ type OnyxProps = { isLoadingReportData: OnyxEntry; }; -type ComponentProps = OnyxProps & { +type WithReportOrNotFoundProps = OnyxProps & { route: RouteProp<{params: {reportID: string}}>; }; export default function ( shouldRequireReportID = true, -): ( +): ( WrappedComponent: React.ComponentType>, ) => React.ComponentType, keyof OnyxProps>> { - return function (WrappedComponent: ComponentType>) { + return function (WrappedComponent: ComponentType>) { function WithReportOrNotFound(props: TProps, ref: ForwardedRef) { const contentShown = React.useRef(false); const isReportIdInRoute = props.route.params.reportID?.length; + // When accessing certain report-dependant pages (e.g. Task Title) by deeplink, the OpenReport API is not called, + // So we need to call OpenReport API here to make sure the report data is loaded if it exists on the Server + useEffect(() => { + if (!isReportIdInRoute || !isEmptyObject(props.report)) { + // If the report is not required or is already loaded, we don't need to call the API + return; + } + + Report.openReport(props.route.params.reportID); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isReportIdInRoute, props.route.params.reportID]); + if (shouldRequireReportID || isReportIdInRoute) { const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.report ?? {}).length || !props.report?.reportID); @@ -89,3 +103,4 @@ export default function ( })(React.forwardRef(WithReportOrNotFound)); }; } +export type {WithReportOrNotFoundProps}; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index f867e57f9a13..6028a735d132 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -8,7 +8,6 @@ import categoryPropTypes from '@components/categoryPropTypes'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestConfirmationList from '@components/MoneyTemporaryForRefactorRequestConfirmationList'; -import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import tagPropTypes from '@components/tagPropTypes'; import transactionPropTypes from '@components/transactionPropTypes'; @@ -24,6 +23,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; +import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportPropTypes from '@pages/reportPropTypes'; import {policyPropTypes} from '@pages/workspace/withPolicy'; import * as IOU from '@userActions/IOU'; @@ -43,6 +43,9 @@ const propTypes = { /** The personal details of the current user */ ...withCurrentUserPersonalDetailsPropTypes, + /** Personal details of all users */ + personalDetails: personalDetailsPropType, + /** The policy of the report */ ...policyPropTypes, @@ -59,6 +62,7 @@ const propTypes = { transaction: transactionPropTypes, }; const defaultProps = { + personalDetails: {}, policy: {}, policyCategories: {}, policyTags: {}, @@ -68,6 +72,7 @@ const defaultProps = { }; function IOURequestStepConfirmation({ currentUserPersonalDetails, + personalDetails, policy, policyTags, policyCategories, @@ -81,7 +86,6 @@ function IOURequestStepConfirmation({ const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); const {isOffline} = useNetwork(); - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const [receiptFile, setReceiptFile] = useState(); const receiptFilename = lodashGet(transaction, 'filename'); const receiptPath = lodashGet(transaction, 'receipt.source'); @@ -381,6 +385,12 @@ export default compose( withCurrentUserPersonalDetails, withWritableReportOrNotFound, withFullTransactionOrNotFound, + withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js new file mode 100644 index 000000000000..1738ac78df47 --- /dev/null +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -0,0 +1,473 @@ +import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import categoryPropTypes from '@components/categoryPropTypes'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MoneyRequestConfirmationList from '@components/MoneyRequestConfirmationList'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import ScreenWrapper from '@components/ScreenWrapper'; +import tagPropTypes from '@components/tagPropTypes'; +import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; +import withLocalize from '@components/withLocalize'; +import useInitialValue from '@hooks/useInitialValue'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import compose from '@libs/compose'; +import * as FileUtils from '@libs/fileDownload/FileUtils'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes'; +import reportPropTypes from '@pages/reportPropTypes'; +import {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy'; +import * as IOU from '@userActions/IOU'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +const propTypes = { + /** React Navigation route */ + route: PropTypes.shape({ + /** Params from the route */ + params: PropTypes.shape({ + /** The type of IOU report, i.e. bill, request, send */ + iouType: PropTypes.string, + + /** The report ID of the IOU */ + reportID: PropTypes.string, + }), + }).isRequired, + + report: reportPropTypes, + + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ + iou: iouPropTypes, + + /** The policy of the current request */ + policy: policyPropTypes, + + policyTags: tagPropTypes, + + policyCategories: PropTypes.objectOf(categoryPropTypes), + + ...withCurrentUserPersonalDetailsPropTypes, +}; + +const defaultProps = { + report: {}, + policyCategories: {}, + policyTags: {}, + iou: iouDefaultProps, + policy: policyDefaultProps, + ...withCurrentUserPersonalDetailsDefaultProps, +}; + +function MoneyRequestConfirmPage(props) { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {windowWidth} = useWindowDimensions(); + const prevMoneyRequestId = useRef(props.iou.id); + const iouType = useInitialValue(() => lodashGet(props.route, 'params.iouType', '')); + const reportID = useInitialValue(() => lodashGet(props.route, 'params.reportID', '')); + const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType, props.selectedTab); + const isScanRequest = MoneyRequestUtils.isScanRequest(props.selectedTab); + const [receiptFile, setReceiptFile] = useState(); + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; + + const participants = useMemo( + () => + _.map(props.iou.participants, (participant) => { + const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false); + return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails); + }), + [props.iou.participants, personalDetails], + ); + const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(props.report)), [props.report]); + const isManualRequestDM = props.selectedTab === CONST.TAB_REQUEST.MANUAL && iouType === CONST.IOU.TYPE.REQUEST; + + useEffect(() => { + const policyExpenseChat = _.find(participants, (participant) => participant.isPolicyExpenseChat); + if (policyExpenseChat) { + Policy.openDraftWorkspaceRequest(policyExpenseChat.policyID); + } + }, [isOffline, participants, props.iou.billable, props.policy]); + + const defaultBillable = lodashGet(props.policy, 'defaultBillable', false); + useEffect(() => { + IOU.setMoneyRequestBillable(defaultBillable); + }, [defaultBillable, isOffline]); + + useEffect(() => { + if (!props.iou.receiptPath || !props.iou.receiptFilename) { + return; + } + const onSuccess = (file) => { + const receipt = file; + receipt.state = file && isManualRequestDM ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCANREADY; + setReceiptFile(receipt); + }; + const onFailure = () => { + Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID)); + }; + FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename, onSuccess, onFailure); + }, [props.iou.receiptPath, props.iou.receiptFilename, isManualRequestDM, iouType, reportID]); + + useEffect(() => { + // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request + if (!isDistanceRequest && prevMoneyRequestId.current !== props.iou.id) { + // The ID is cleared on completing a request. In that case, we will do nothing. + if (props.iou.id) { + Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID), true); + } + return; + } + + // Reset the money request Onyx if the ID in Onyx does not match the ID from params + const moneyRequestId = `${iouType}${reportID}`; + const shouldReset = !isDistanceRequest && props.iou.id !== moneyRequestId && !_.isEmpty(reportID); + if (shouldReset) { + IOU.resetMoneyRequestInfo(moneyRequestId); + } + + 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, reportID), true); + } + + return () => { + prevMoneyRequestId.current = props.iou.id; + }; + }, [props.iou.participants, props.iou.amount, props.iou.id, props.iou.receiptPath, isDistanceRequest, props.report, iouType, reportID]); + + const navigateBack = () => { + let fallback; + if (reportID) { + fallback = ROUTES.MONEY_REQUEST.getRoute(iouType, reportID); + } else { + fallback = ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(iouType); + } + Navigation.goBack(fallback); + }; + + /** + * @param {Array} selectedParticipants + * @param {String} trimmedComment + * @param {File} [receipt] + */ + const requestMoney = useCallback( + (selectedParticipants, trimmedComment, receipt) => { + IOU.requestMoney( + props.report, + props.iou.amount, + props.iou.currency, + props.iou.created, + props.iou.merchant, + props.currentUserPersonalDetails.login, + props.currentUserPersonalDetails.accountID, + selectedParticipants[0], + trimmedComment, + receipt, + props.iou.category, + props.iou.tag, + props.iou.billable, + props.policy, + props.policyTags, + props.policyCategories, + ); + }, + [ + props.report, + props.iou.amount, + props.iou.currency, + props.iou.created, + props.iou.merchant, + props.currentUserPersonalDetails.login, + props.currentUserPersonalDetails.accountID, + props.iou.category, + props.iou.tag, + props.iou.billable, + props.policy, + props.policyTags, + props.policyCategories, + ], + ); + + /** + * @param {Array} selectedParticipants + * @param {String} trimmedComment + */ + const createDistanceRequest = useCallback( + (selectedParticipants, trimmedComment) => { + IOU.createDistanceRequest( + props.report, + selectedParticipants[0], + trimmedComment, + props.iou.created, + props.iou.transactionID, + props.iou.category, + props.iou.tag, + props.iou.amount, + props.iou.currency, + props.iou.merchant, + props.iou.billable, + props.policy, + props.policyTags, + props.policyCategories, + ); + }, + [ + props.report, + props.iou.created, + props.iou.transactionID, + props.iou.category, + props.iou.tag, + props.iou.amount, + props.iou.currency, + props.iou.merchant, + props.iou.billable, + props.policy, + props.policyTags, + props.policyCategories, + ], + ); + + const createTransaction = useCallback( + (selectedParticipants) => { + const trimmedComment = props.iou.comment.trim(); + + // If we have a receipt let's start the split bill by creating only the action, the transaction, and the group DM if needed + if (iouType === CONST.IOU.TYPE.SPLIT && props.iou.receiptPath) { + const existingSplitChatReportID = CONST.REGEX.NUMBER.test(reportID) ? reportID : ''; + const onSuccess = (receipt) => { + IOU.startSplitBill( + selectedParticipants, + props.currentUserPersonalDetails.login, + props.currentUserPersonalDetails.accountID, + trimmedComment, + receipt, + existingSplitChatReportID, + ); + }; + FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename, onSuccess); + return; + } + + // IOUs created from a group report will have a reportID param in the route. + // Since the user is already viewing the report, we don't need to navigate them to the report + if (iouType === CONST.IOU.TYPE.SPLIT && CONST.REGEX.NUMBER.test(reportID)) { + IOU.splitBill( + selectedParticipants, + props.currentUserPersonalDetails.login, + props.currentUserPersonalDetails.accountID, + props.iou.amount, + trimmedComment, + props.iou.currency, + props.iou.category, + props.iou.tag, + reportID, + props.iou.merchant, + ); + return; + } + + // If the request is created from the global create menu, we also navigate the user to the group report + if (iouType === CONST.IOU.TYPE.SPLIT) { + IOU.splitBillAndOpenReport( + selectedParticipants, + props.currentUserPersonalDetails.login, + props.currentUserPersonalDetails.accountID, + props.iou.amount, + trimmedComment, + props.iou.currency, + props.iou.category, + props.iou.tag, + props.iou.merchant, + ); + return; + } + + if (receiptFile) { + requestMoney(selectedParticipants, trimmedComment, receiptFile); + return; + } + + if (isDistanceRequest) { + createDistanceRequest(selectedParticipants, trimmedComment); + return; + } + + requestMoney(selectedParticipants, trimmedComment); + }, + [ + props.iou.amount, + props.iou.comment, + props.currentUserPersonalDetails.login, + props.currentUserPersonalDetails.accountID, + props.iou.currency, + props.iou.category, + props.iou.tag, + props.iou.receiptPath, + props.iou.receiptFilename, + isDistanceRequest, + requestMoney, + createDistanceRequest, + receiptFile, + iouType, + reportID, + props.iou.merchant, + ], + ); + + /** + * Checks if user has a GOLD wallet then creates a paid IOU report on the fly + * + * @param {String} paymentMethodType + */ + const sendMoney = useCallback( + (paymentMethodType) => { + const currency = props.iou.currency; + const trimmedComment = props.iou.comment.trim(); + const participant = participants[0]; + + if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + IOU.sendMoneyElsewhere(props.report, props.iou.amount, currency, trimmedComment, props.currentUserPersonalDetails.accountID, participant); + return; + } + + if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + IOU.sendMoneyWithWallet(props.report, props.iou.amount, currency, trimmedComment, props.currentUserPersonalDetails.accountID, participant); + } + }, + [props.iou.amount, props.iou.comment, participants, props.iou.currency, props.currentUserPersonalDetails.accountID, props.report], + ); + + const headerTitle = () => { + if (isDistanceRequest) { + return props.translate('common.distance'); + } + + if (iouType === CONST.IOU.TYPE.SPLIT) { + return props.translate('iou.split'); + } + + if (iouType === CONST.IOU.TYPE.SEND) { + return props.translate('common.send'); + } + + if (isScanRequest) { + return props.translate('tabSelector.scan'); + } + + return props.translate('tabSelector.manual'); + }; + + return ( + + {({safeAreaPaddingBottomStyle}) => ( + + Navigation.navigate(ROUTES.MONEY_REQUEST_RECEIPT.getRoute(iouType, reportID)), + }, + ]} + /> + { + const newParticipants = _.map(props.iou.participants, (participant) => { + if (participant.accountID === option.accountID) { + return {...participant, selected: !participant.selected}; + } + return participant; + }); + IOU.setMoneyRequestParticipants(newParticipants); + }} + receiptPath={props.iou.receiptPath} + receiptFilename={props.iou.receiptFilename} + iouType={iouType} + reportID={reportID} + isPolicyExpenseChat={isPolicyExpenseChat} + // The participants can only be modified when the action is initiated from directly within a group chat and not the floating-action-button. + // This is because when there is a group of people, say they are on a trip, and you have some shared expenses with some of the people, + // but not all of them (maybe someone skipped out on dinner). Then it's nice to be able to select/deselect people from the group chat bill + // split rather than forcing the user to create a new group, just for that expense. The reportID is empty, when the action was initiated from + // the floating-action-button (since it is something that exists outside the context of a report). + canModifyParticipants={!_.isEmpty(reportID)} + policyID={props.report.policyID} + bankAccountRoute={ReportUtils.getBankAccountRoute(props.report)} + iouMerchant={props.iou.merchant} + iouCreated={props.iou.created} + isScanRequest={isScanRequest} + isDistanceRequest={isDistanceRequest} + shouldShowSmartScanFields={_.isEmpty(props.iou.receiptPath)} + /> + + )} + + ); +} + +MoneyRequestConfirmPage.displayName = 'MoneyRequestConfirmPage'; +MoneyRequestConfirmPage.propTypes = propTypes; +MoneyRequestConfirmPage.defaultProps = defaultProps; + +export default compose( + withCurrentUserPersonalDetails, + withLocalize, + withOnyx({ + iou: { + key: ONYXKEYS.IOU, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file + withOnyx({ + report: { + key: ({route, iou}) => { + const reportID = IOU.getIOUReportID(iou, route); + + return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; + }, + }, + selectedTab: { + key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, + }, + }), + withOnyx({ + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, + }, + policyCategories: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`, + }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, + }, + }), +)(MoneyRequestConfirmPage); diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index 8ea471283004..3269fc401c01 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -60,13 +60,16 @@ function DisplayNamePage(props) { if (!ValidationUtils.isValidDisplayName(values.firstName)) { ErrorUtils.addErrorMessage(errors, 'firstName', 'personalDetails.error.hasInvalidCharacter'); } - if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_FIRST_NAMES)) { + if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { ErrorUtils.addErrorMessage(errors, 'firstName', 'personalDetails.error.containsReservedWord'); } // Then we validate the last name field if (!ValidationUtils.isValidDisplayName(values.lastName)) { - errors.lastName = 'personalDetails.error.hasInvalidCharacter'; + ErrorUtils.addErrorMessage(errors, 'lastName', 'personalDetails.error.hasInvalidCharacter'); + } + if (ValidationUtils.doesContainReservedWord(values.lastName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { + ErrorUtils.addErrorMessage(errors, 'lastName', 'personalDetails.error.containsReservedWord'); } return errors; }; diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js index 365ea62184ab..2943bcf19992 100644 --- a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js +++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js @@ -60,6 +60,9 @@ function LegalNamePage(props) { } else if (_.isEmpty(values.legalFirstName)) { errors.legalFirstName = 'common.error.fieldRequired'; } + if (ValidationUtils.doesContainReservedWord(values.legalFirstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { + ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'personalDetails.error.containsReservedWord'); + } if (values.legalFirstName.length > CONST.LEGAL_NAME.MAX_LENGTH) { ErrorUtils.addErrorMessage(errors, 'legalFirstName', ['common.error.characterLimitExceedCounter', {length: values.legalFirstName.length, limit: CONST.LEGAL_NAME.MAX_LENGTH}]); } @@ -69,6 +72,9 @@ function LegalNamePage(props) { } else if (_.isEmpty(values.legalLastName)) { errors.legalLastName = 'common.error.fieldRequired'; } + if (ValidationUtils.doesContainReservedWord(values.legalLastName, CONST.DISPLAY_NAME.RESERVED_NAMES)) { + ErrorUtils.addErrorMessage(errors, 'legalLastName', 'personalDetails.error.containsReservedWord'); + } if (values.legalLastName.length > CONST.LEGAL_NAME.MAX_LENGTH) { ErrorUtils.addErrorMessage(errors, 'legalLastName', ['common.error.characterLimitExceedCounter', {length: values.legalLastName.length, limit: CONST.LEGAL_NAME.MAX_LENGTH}]); } diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 7e9d8158a9ae..729c41cd1f6b 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -277,14 +277,15 @@ function SignInPageInner({credentials, account, isInModal, activeClients, prefer blurOnSubmit={account.validated === false} scrollPageToTop={signInPageLayoutRef.current && signInPageLayoutRef.current.scrollPageToTop} /> + {shouldShowValidateCodeForm && ( + + )} {!shouldShowAnotherLoginPageOpenedMessage && ( <> - {shouldShowValidateCodeForm && ( - - )} {shouldShowUnlinkLoginForm && } {shouldShowChooseSSOOrMagicCode && } {shouldShowEmailDeliveryFailurePage && } diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index b881a6001710..fd5e9b952612 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -1,3 +1,4 @@ +import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -13,6 +14,7 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import withToggleVisibilityView from '@components/withToggleVisibilityView'; import usePrevious from '@hooks/usePrevious'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -85,6 +87,7 @@ function BaseValidateCodeForm(props) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const isFocused = useIsFocused(); const [formError, setFormError] = useState({}); const [validateCode, setValidateCode] = useState(props.credentials.validateCode || ''); const [twoFactorAuthCode, setTwoFactorAuthCode] = useState(''); @@ -113,11 +116,11 @@ function BaseValidateCodeForm(props) { }, [props.account.isLoading, props.session.autoAuthState, hasError]); useEffect(() => { - if (!inputValidateCodeRef.current || !canFocusInputOnScreenFocus()) { + if (!inputValidateCodeRef.current || !canFocusInputOnScreenFocus() || !props.isVisible || !isFocused) { return; } inputValidateCodeRef.current.focus(); - }, []); + }, [props.isVisible, isFocused]); useEffect(() => { if (prevValidateCode || !props.credentials.validateCode) { @@ -431,4 +434,5 @@ export default compose( session: {key: ONYXKEYS.SESSION}, }), withNetwork(), + withToggleVisibilityView, )(BaseValidateCodeForm); diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index 6c8476fed5cb..0c9acb1a40e8 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -215,22 +215,25 @@ function WorkspaceInitialPage(props) { onSelected: () => setIsDeleteModalOpen(true), }, ]; - if (adminsRoom) { + // Menu options to navigate to the chat report of #admins and #announce room. + // For navigation, the chat report ids may be unavailable due to the missing chat reports in Onyx. + // In such cases, let us use the available chat report ids from the policy. + if (adminsRoom || policy.chatReportIDAdmins) { items.push({ icon: Expensicons.Hashtag, text: translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS}), - onSelected: () => Navigation.dismissModal(adminsRoom.reportID), + onSelected: () => Navigation.dismissModal(adminsRoom ? adminsRoom.reportID : policy.chatReportIDAdmins.toString()), }); } - if (announceRoom) { + if (announceRoom || policy.chatReportIDAnnounce) { items.push({ icon: Expensicons.Hashtag, text: translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE}), - onSelected: () => Navigation.dismissModal(announceRoom.reportID), + onSelected: () => Navigation.dismissModal(announceRoom ? announceRoom.reportID : policy.chatReportIDAnnounce.toString()), }); } return items; - }, [adminsRoom, announceRoom, translate]); + }, [adminsRoom, announceRoom, translate, policy]); const prevPolicy = usePrevious(policy);