diff --git a/android/app/build.gradle b/android/app/build.gradle index b67cb294e29b..4ea2098787f6 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 1001043400 - versionName "1.4.34-0" + versionCode 1001043500 + versionName "1.4.35-0" } flavorDimensions "default" diff --git a/assets/images/new-expensify.svg b/assets/images/new-expensify.svg index 264821d4f86e..7bfef1fd38b4 100644 --- a/assets/images/new-expensify.svg +++ b/assets/images/new-expensify.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/docs/articles/new-expensify/account-settings/Preferences.md b/docs/articles/new-expensify/account-settings/Preferences.md new file mode 100644 index 000000000000..b94c9d35c1a1 --- /dev/null +++ b/docs/articles/new-expensify/account-settings/Preferences.md @@ -0,0 +1,21 @@ +--- +title: Preferences +description: How to manage your Expensify Preferences +--- +# Overview +Your Preferences in Expensify allow you to customize how you use New Expensify. + +- Set your theme preference + +# How to set your theme preference in New Expensify + +To set or update your theme preference in New Expensify: +1. Go to **Settings > Preferences** +2. Tap on **Theme** +3. You can choose between the _Dark_ theme, the _Light_ theme, or _Use Device Settings_ + +_Use Device Settings_ is the default setting. + +Selecting _Use Device Settings_ will use your device's theme settings. For example, if your device is set to adjust the appearance from light to dark during the day, we'll match that. + +Your theme preference will sync across all your New Expensify apps (mobile, web, or OSX desktop apps). diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 364f33a02c30..341edd0c9dfe 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.34 + 1.4.35 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.34.0 + 1.4.35.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 6a90575d81fd..6b39db8b2f27 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.34 + 1.4.35 CFBundleSignature ???? CFBundleVersion - 1.4.34.0 + 1.4.35.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index e90a61461c2b..ff9b56d72408 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.34 + 1.4.35 CFBundleVersion - 1.4.34.0 + 1.4.35.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index e31aa2e80538..acadf891d8d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.34-0", + "version": "1.4.35-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.34-0", + "version": "1.4.35-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 92c906251fdb..bdbdb27dbf59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.34-0", + "version": "1.4.35-0", "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/ONYXKEYS.ts b/src/ONYXKEYS.ts index c90a7a6a7c74..2867cb3905a2 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -243,6 +243,9 @@ const ONYXKEYS = { // Max width supported for HTML element MAX_CANVAS_WIDTH: 'maxCanvasWidth', + // Stores the recently used report fields + RECENTLY_USED_REPORT_FIELDS: 'recentlyUsedReportFields', + /** Indicates whether an forced upgrade is required */ UPDATE_REQUIRED: 'updateRequired', @@ -259,7 +262,6 @@ const ONYXKEYS = { POLICY_TAX_RATE: 'policyTaxRates_', POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', POLICY_REPORT_FIELDS: 'policyReportFields_', - POLICY_RECENTLY_USED_REPORT_FIELDS: 'policyRecentlyUsedReportFields_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', REPORT: 'report_', @@ -356,8 +358,8 @@ const ONYXKEYS = { REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm', GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', - POLICY_REPORT_FIELD_EDIT_FORM: 'policyReportFieldEditForm', - POLICY_REPORT_FIELD_EDIT_FORM_DRAFT: 'policyReportFieldEditFormDraft', + REPORT_FIELD_EDIT_FORM: 'reportFieldEditForm', + REPORT_FIELD_EDIT_FORM_DRAFT: 'reportFieldEditFormDraft', REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft', PERSONAL_BANK_ACCOUNT: 'personalBankAccountForm', @@ -445,6 +447,7 @@ type OnyxValues = { [ONYXKEYS.MAX_CANVAS_AREA]: number; [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; + [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.UPDATE_REQUIRED]: boolean; // Collections @@ -457,7 +460,6 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; - [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record; [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; @@ -542,8 +544,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: OnyxTypes.ReportFieldEditForm; + [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form; // @ts-expect-error Different values are defined under the same key: ReimbursementAccount and ReimbursementAccountForm [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; diff --git a/src/components/AnonymousReportFooter.tsx b/src/components/AnonymousReportFooter.tsx index 7d00bac54dca..078b850de5ff 100644 --- a/src/components/AnonymousReportFooter.tsx +++ b/src/components/AnonymousReportFooter.tsx @@ -1,27 +1,31 @@ import React from 'react'; import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@userActions/Session'; -import type {PersonalDetailsList, Report} from '@src/types/onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; import AvatarWithDisplayName from './AvatarWithDisplayName'; import Button from './Button'; import ExpensifyWordmark from './ExpensifyWordmark'; import Text from './Text'; -type AnonymousReportFooterProps = { +type AnonymousReportFooterPropsWithOnyx = { + /** The policy which the user has access to and which the report is tied to */ + policy: OnyxEntry; +}; + +type AnonymousReportFooterProps = AnonymousReportFooterPropsWithOnyx & { /** The report currently being looked at */ report: OnyxEntry; /** Whether the small screen size layout should be used */ isSmallSizeLayout?: boolean; - - /** Personal details of all the users */ - personalDetails: OnyxEntry; }; -function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, report}: AnonymousReportFooterProps) { +function AnonymousReportFooter({isSmallSizeLayout = false, report, policy}: AnonymousReportFooterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -30,9 +34,9 @@ function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, repo @@ -57,4 +61,8 @@ function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, repo AnonymousReportFooter.displayName = 'AnonymousReportFooter'; -export default AnonymousReportFooter; +export default withOnyx({ + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, + }, +})(AnonymousReportFooter); diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index f3e8ed316c52..90954c63b751 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -1,5 +1,5 @@ import Str from 'expensify-common/lib/str'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; import {Animated, Keyboard, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; @@ -130,6 +130,9 @@ type AttachmentModalProps = AttachmentModalOnyxProps & { /** Denotes whether it is a workspace avatar or not */ isWorkspaceAvatar?: boolean; + /** Denotes whether it can be an icon (ex: SVG) */ + maybeIcon?: boolean; + /** Whether it is a receipt attachment or not */ isReceiptAttachment?: boolean; @@ -154,6 +157,7 @@ function AttachmentModal({ onCarouselAttachmentChange = () => {}, isReceiptAttachment = false, isWorkspaceAvatar = false, + maybeIcon = false, transaction, parentReport, parentReportActions, @@ -531,6 +535,7 @@ function AttachmentModal({ file={file} onToggleKeyboard={updateConfirmButtonVisibility} isWorkspaceAvatar={isWorkspaceAvatar} + maybeIcon={maybeIcon} fallbackSource={fallbackSource} isUsedInAttachmentModal transactionID={transaction?.transactionID} @@ -610,6 +615,6 @@ export default withOnyx({ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, canEvict: false, }, -})(AttachmentModal); +})(memo(AttachmentModal)); export type {Attachment}; diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index 67f6dd95568e..33eab13f3851 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -48,6 +48,9 @@ const propTypes = { /** Denotes whether it is a workspace avatar or not */ isWorkspaceAvatar: PropTypes.bool, + /** Denotes whether it is an icon (ex: SVG) */ + maybeIcon: PropTypes.bool, + /** The id of the transaction related to the attachment */ // eslint-disable-next-line react/no-unused-prop-types transactionID: PropTypes.string, @@ -60,6 +63,7 @@ const defaultProps = { onToggleKeyboard: () => {}, containerStyles: [], isWorkspaceAvatar: false, + maybeIcon: false, transactionID: '', }; @@ -80,6 +84,7 @@ function AttachmentView({ carouselActiveItemIndex, isUsedInAttachmentModal, isWorkspaceAvatar, + maybeIcon, fallbackSource, transaction, }) { @@ -91,8 +96,9 @@ function AttachmentView({ useNetwork({onReconnect: () => setImageError(false)}); - // Handles case where source is a component (ex: SVG) - if (_.isFunction(source)) { + // Handles case where source is a component (ex: SVG) or a number + // Number may represent a SVG or an image + if ((maybeIcon && typeof source === 'number') || _.isFunction(source)) { let iconFillColor = ''; let additionalStyles = []; if (isWorkspaceAvatar) { diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index d42d47caafc9..16f31b9c7eba 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -12,7 +12,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; +import type {PersonalDetails, Policy, Report, ReportActions} from '@src/types/onyx'; import DisplayNames from './DisplayNames'; import MultipleAvatars from './MultipleAvatars'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; @@ -23,6 +23,9 @@ import Text from './Text'; type AvatarWithDisplayNamePropsWithOnyx = { /** All of the actions of the report */ parentReportActions: OnyxEntry; + + /** Personal details of all users */ + personalDetails: OnyxCollection; }; type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { @@ -35,9 +38,6 @@ type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { /** The size of the avatar */ size?: ValueOf; - /** Personal details of all the users */ - personalDetails: OnyxEntry; - /** Whether if it's an unauthenticated user */ isAnonymous?: boolean; @@ -46,13 +46,13 @@ type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { }; function AvatarWithDisplayName({ - personalDetails, policy, report, parentReportActions, isAnonymous = false, size = CONST.AVATAR_SIZE.DEFAULT, shouldEnableDetailPageNavigation = false, + personalDetails = CONST.EMPTY_OBJECT, }: AvatarWithDisplayNameProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -181,4 +181,7 @@ export default withOnyx `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, canEvict: false, }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, })(AvatarWithDisplayName); diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 0b1b91b28964..476fd329f4a3 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -338,6 +338,7 @@ function AvatarWithImagePicker({ source={previewSource} originalFileName={originalFileName} fallbackSource={fallbackIcon} + maybeIcon={isUsingDefaultAvatar} > {({show}) => ( diff --git a/src/components/ButtonWithDropdownMenu.tsx b/src/components/ButtonWithDropdownMenu.tsx index 466c68229a32..676912de6b60 100644 --- a/src/components/ButtonWithDropdownMenu.tsx +++ b/src/components/ButtonWithDropdownMenu.tsx @@ -32,6 +32,9 @@ type ButtonWithDropdownMenuProps = { /** Callback to execute when the main button is pressed */ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: string) => void; + /** Callback to execute when a dropdown option is selected */ + onOptionSelected?: (option: DropdownOption) => void; + /** Call the onPress function on main button when Enter key is pressed */ pressOnEnter?: boolean; @@ -72,6 +75,7 @@ function ButtonWithDropdownMenu({ buttonRef, onPress, options, + onOptionSelected, }: ButtonWithDropdownMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -174,6 +178,7 @@ function ButtonWithDropdownMenu({ menuItems={options.map((item, index) => ({ ...item, onSelected: () => { + onOptionSelected?.(item); setSelectedItemIndex(index); }, }))} diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index 832715e3214c..438deb7e53d9 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {useEffect, useRef} from 'react'; +import React, {memo, useEffect, useRef} from 'react'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -87,4 +87,4 @@ function EmojiPickerButton(props) { EmojiPickerButton.propTypes = propTypes; EmojiPickerButton.defaultProps = defaultProps; EmojiPickerButton.displayName = 'EmojiPickerButton'; -export default compose(withLocalize, withNavigationFocus)(EmojiPickerButton); +export default compose(withLocalize, withNavigationFocus)(memo(EmojiPickerButton)); diff --git a/src/components/ExceededCommentLength.tsx b/src/components/ExceededCommentLength.tsx index 6cd11cc44a5c..2f0887afc8f1 100644 --- a/src/components/ExceededCommentLength.tsx +++ b/src/components/ExceededCommentLength.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {memo} from 'react'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -20,4 +20,4 @@ function ExceededCommentLength() { ExceededCommentLength.displayName = 'ExceededCommentLength'; -export default ExceededCommentLength; +export default memo(ExceededCommentLength); diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 054e3123b949..ec9d118e66a5 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -30,7 +30,6 @@ function HeaderWithBackButton({ onThreeDotsButtonPress = () => {}, report = null, policy, - personalDetails = null, shouldShowAvatarWithDisplay = false, shouldShowBackButton = true, shouldShowBorderBottom = false, @@ -104,7 +103,6 @@ function HeaderWithBackButton({ ) : ( diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 4b4e3915f969..5209ab894828 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -22,6 +22,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import Button from './Button'; import HeaderWithBackButton from './HeaderWithBackButton'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; +import {usePersonalDetails} from './OnyxProvider'; import SettlementButton from './SettlementButton'; type PaymentType = DeepValueOf; @@ -43,12 +44,10 @@ type MoneyReportHeaderProps = MoneyReportHeaderOnyxProps & { /** The policy tied to the money request report */ policy: OnyxTypes.Policy; - - /** Personal details so we can get the ones for the report participants */ - personalDetails: OnyxTypes.PersonalDetailsList; }; -function MoneyReportHeader({session, personalDetails, policy, chatReport, nextStep, report: moneyRequestReport}: MoneyReportHeaderProps) { +function MoneyReportHeader({session, policy, chatReport, nextStep, report: moneyRequestReport}: MoneyReportHeaderProps) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); const {translate} = useLocalize(); const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); @@ -86,8 +85,8 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt // The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on const isWaitingForSubmissionFromCurrentUser = useMemo( - () => chatReport?.isOwnPolicyExpenseChat && !policy.isHarvestingEnabled, - [chatReport?.isOwnPolicyExpenseChat, policy.isHarvestingEnabled], + () => chatReport?.isOwnPolicyExpenseChat && !(policy.harvesting?.enabled ?? policy.isHarvestingEnabled), + [chatReport?.isOwnPolicyExpenseChat, policy.harvesting?.enabled, policy.isHarvestingEnabled], ); const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(moneyRequestReport)]; diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index d967d04ab94b..afabb40fd9f4 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -731,8 +731,14 @@ function MoneyRequestConfirmationList(props) { }} disabled={didConfirm} interactive={!props.isReadOnly} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={shouldDisplayMerchantError || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction)) ? translate('common.error.enterMerchant') : ''} + brickRoadIndicator={ + props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '' + } + error={ + shouldDisplayMerchantError || (props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction)) + ? translate('common.error.enterMerchant') + : '' + } /> )} {shouldShowCategories && ( diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index be42abc797dd..e907f798051b 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -22,7 +22,7 @@ import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import * as Expensicons from './Icon/Expensicons'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; -import participantPropTypes from './participantPropTypes'; +import {usePersonalDetails} from './OnyxProvider'; import transactionPropTypes from './transactionPropTypes'; const propTypes = { @@ -35,9 +35,6 @@ const propTypes = { name: PropTypes.string, }), - /** Personal details so we can get the ones for the report participants */ - personalDetails: PropTypes.objectOf(participantPropTypes).isRequired, - /* Onyx Props */ /** Session info for the currently logged in user. */ session: PropTypes.shape({ @@ -65,7 +62,8 @@ const defaultProps = { policy: {}, }; -function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy, personalDetails}) { +function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy}) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); const {translate} = useLocalize(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); diff --git a/src/components/PurposeForUsingExpensifyModal.tsx b/src/components/PurposeForUsingExpensifyModal.tsx index c02815d74153..d464296edd84 100644 --- a/src/components/PurposeForUsingExpensifyModal.tsx +++ b/src/components/PurposeForUsingExpensifyModal.tsx @@ -1,6 +1,8 @@ import {useNavigation} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -11,6 +13,7 @@ import * as Report from '@userActions/Report'; import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import HeaderWithBackButton from './HeaderWithBackButton'; import * as Expensicons from './Icon/Expensicons'; @@ -78,8 +81,12 @@ const menuIcons = { [CONST.INTRO_CHOICES.MANAGE_TEAM]: Expensicons.MoneyBag, [CONST.INTRO_CHOICES.CHAT_SPLIT]: Expensicons.Briefcase, }; +type PurposeForUsingExpensifyModalOnyxProps = { + isLoadingApp: OnyxEntry; +}; +type PurposeForUsingExpensifyModalProps = PurposeForUsingExpensifyModalOnyxProps; -function PurposeForUsingExpensifyModal() { +function PurposeForUsingExpensifyModal({isLoadingApp = false}: PurposeForUsingExpensifyModalProps) { const {translate} = useLocalize(); const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); @@ -98,7 +105,7 @@ function PurposeForUsingExpensifyModal() { Welcome.show(routes, () => setIsModalOpen(true)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isLoadingApp]); const closeModal = useCallback(() => { Report.dismissEngagementModal(); @@ -174,5 +181,8 @@ function PurposeForUsingExpensifyModal() { } PurposeForUsingExpensifyModal.displayName = 'PurposeForUsingExpensifyModal'; - -export default PurposeForUsingExpensifyModal; +export default withOnyx({ + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, +})(PurposeForUsingExpensifyModal); diff --git a/src/components/ReportActionItem/ChronosOOOListActions.js b/src/components/ReportActionItem/ChronosOOOListActions.tsx similarity index 58% rename from src/components/ReportActionItem/ChronosOOOListActions.js rename to src/components/ReportActionItem/ChronosOOOListActions.tsx index f90ae67796b9..52e0c52873d6 100644 --- a/src/components/ReportActionItem/ChronosOOOListActions.js +++ b/src/components/ReportActionItem/ChronosOOOListActions.tsx @@ -1,58 +1,57 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; import Button from '@components/Button'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import * as Chronos from '@userActions/Chronos'; +import type {OriginalMessageChronosOOOList} from '@src/types/onyx/OriginalMessage'; +import type {ReportActionBase} from '@src/types/onyx/ReportAction'; -const propTypes = { +type ChronosOOOListActionsProps = { /** The ID of the report */ - reportID: PropTypes.string.isRequired, + reportID: string; /** All the data of the action */ - action: PropTypes.shape(reportActionPropTypes).isRequired, - - ...withLocalizePropTypes, + action: ReportActionBase & OriginalMessageChronosOOOList; }; -function ChronosOOOListActions(props) { +function ChronosOOOListActions({reportID, action}: ChronosOOOListActionsProps) { const styles = useThemeStyles(); - const events = lodashGet(props.action, 'originalMessage.events', []); + + const {translate, preferredLocale} = useLocalize(); + + const events = action.originalMessage?.events ?? []; if (!events.length) { return ( - + You haven't created any events ); } return ( - - - {_.map(events, (event) => { - const start = DateUtils.getLocalDateFromDatetime(props.preferredLocale, lodashGet(event, 'start.date', '')); - const end = DateUtils.getLocalDateFromDatetime(props.preferredLocale, lodashGet(event, 'end.date', '')); + + + {events.map((event) => { + const start = DateUtils.getLocalDateFromDatetime(preferredLocale, event?.start?.date ?? ''); + const end = DateUtils.getLocalDateFromDatetime(preferredLocale, event?.end?.date ?? ''); return ( - + {event.lengthInDays > 0 - ? props.translate('chronos.oooEventSummaryFullDay', { + ? translate('chronos.oooEventSummaryFullDay', { summary: event.summary, dayCount: event.lengthInDays, date: DateUtils.formatToLongDateWithWeekday(end), }) - : props.translate('chronos.oooEventSummaryPartialDay', { + : translate('chronos.oooEventSummaryPartialDay', { summary: event.summary, timePeriod: `${DateUtils.formatToLocalTime(start)} - ${DateUtils.formatToLocalTime(end)}`, date: DateUtils.formatToLongDateWithWeekday(end), @@ -60,10 +59,10 @@ function ChronosOOOListActions(props) { ); @@ -73,7 +72,6 @@ function ChronosOOOListActions(props) { ); } -ChronosOOOListActions.propTypes = propTypes; ChronosOOOListActions.displayName = 'ChronosOOOListActions'; -export default withLocalize(ChronosOOOListActions); +export default ChronosOOOListActions; diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index ed7c05b828a9..3c0e50b2c940 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -1,8 +1,7 @@ +import Str from 'expensify-common/lib/str'; import React, {useMemo} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -20,14 +19,16 @@ import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import variables from '@styles/variables'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, PolicyReportField, Report} from '@src/types/onyx'; -type MoneyReportViewComponentProps = { +type MoneyReportViewProps = { /** The report currently being looked at */ report: Report; + /** Policy that the report belongs to */ + policy: Policy; + /** Policy report fields */ policyReportFields: PolicyReportField[]; @@ -35,14 +36,7 @@ type MoneyReportViewComponentProps = { shouldShowHorizontalRule: boolean; }; -type MoneyReportViewOnyxProps = { - /** Policies that the user is part of */ - policies: OnyxCollection; -}; - -type MoneyReportViewProps = MoneyReportViewComponentProps & MoneyReportViewOnyxProps; - -function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule, policies}: MoneyReportViewProps) { +function MoneyReportView({report, policy, policyReportFields, shouldShowHorizontalRule}: MoneyReportViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -65,30 +59,34 @@ function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule, StyleUtils.getColorStyle(theme.textSupporting), ]; - const sortedPolicyReportFields = useMemo( - () => policyReportFields.sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight), - [policyReportFields], - ); - const isAdmin = ReportUtils.isPolicyAdmin(report.policyID ?? '', policies); + const sortedPolicyReportFields = useMemo((): PolicyReportField[] => { + const fields = ReportUtils.getAvailableReportFields(report, policyReportFields); + return fields.sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight); + }, [policyReportFields, report]); + return ( {canUseReportFields && sortedPolicyReportFields.map((reportField) => { - const title = ReportUtils.getReportFieldTitle(report, reportField); - const isDisabled = !isAdmin || isSettled || ReportUtils.isReportFieldOfTypeTitle(reportField); + const isTitleField = ReportUtils.isReportFieldOfTypeTitle(reportField); + const fieldValue = isTitleField ? report.reportName : reportField.value ?? reportField.defaultValue; + const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); + return ( Navigation.navigate(ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report.reportID, report.policyID ?? '', reportField.fieldID))} shouldShowRightIcon - disabled={isDisabled} + disabled={isFieldDisabled} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} shouldGreyOutWhenDisabled={false} numberOfLinesTitle={0} @@ -178,8 +176,4 @@ function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule, MoneyReportView.displayName = 'MoneyReportView'; -export default withOnyx({ - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, -})(MoneyReportView); +export default MoneyReportView; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index b2fece085f57..52e9d94eaefd 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -159,8 +159,8 @@ function ReportPreview({ // The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on const isWaitingForSubmissionFromCurrentUser = useMemo( - () => chatReport?.isOwnPolicyExpenseChat && !policy?.isHarvestingEnabled, - [chatReport?.isOwnPolicyExpenseChat, policy?.isHarvestingEnabled], + () => chatReport?.isOwnPolicyExpenseChat && !(policy?.harvesting?.enabled ?? policy?.isHarvestingEnabled), + [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled, policy?.isHarvestingEnabled], ); const getDisplayAmount = (): string => { diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index 0c8e193af4cc..493b8767ee9b 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -177,8 +177,9 @@ function SettlementButton({ return [approveButtonOption]; } - // To achieve the one tap pay experience we need to choose the correct payment type as default, - // if user already paid for some request or expense, let's use the last payment method or use default. + // To achieve the one tap pay experience we need to choose the correct payment type as default. + // If the user has previously chosen a specific payment option or paid for some request or expense, + // let's use the last payment method or use default. const paymentMethod = nvp_lastPaymentMethod[policyID] || ''; if (canUseWallet) { buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]); @@ -192,12 +193,14 @@ function SettlementButton({ buttonOptions.push(approveButtonOption); } - // Put the preferred payment method to the front of the array so its shown as default + // Put the preferred payment method to the front of the array, so it's shown as default if (paymentMethod) { return _.sortBy(buttonOptions, (method) => (method.value === paymentMethod ? 0 : 1)); } return buttonOptions; - }, [currency, formattedAmount, iouReport, nvp_lastPaymentMethod, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton]); + // We don't want to reorder the options when the preferred payment method changes while the button is still visible + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currency, formattedAmount, iouReport, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton]); const selectPaymentType = (event, iouPaymentType, triggerKYCFlow) => { if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { @@ -235,6 +238,7 @@ function SettlementButton({ onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType, triggerKYCFlow)} pressOnEnter={pressOnEnter} options={paymentButtonOptions} + onOptionSelected={(option) => IOU.savePreferredPaymentMethod(policyID, option.value)} style={style} buttonSize={buttonSize} anchorAlignment={paymentMethodDropdownAnchorAlignment} diff --git a/src/languages/en.ts b/src/languages/en.ts index 58af1c6638c4..ab687e284b10 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1889,6 +1889,8 @@ export default { report: { genericCreateReportFailureMessage: 'Unexpected error creating this chat, please try again later', genericAddCommentFailureMessage: 'Unexpected error while posting the comment, please try again later', + genericUpdateReportFieldFailureMessage: 'Unexpected error while updating the field, please try again later', + genericUpdateReporNameEditFailureMessage: 'Unexpected error while renaming the report, please try again later', noActivityYet: 'No activity yet', }, chronos: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 427097d5d16b..167f740c4829 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1915,6 +1915,8 @@ export default { report: { genericCreateReportFailureMessage: 'Error inesperado al crear el chat. Por favor, inténtalo más tarde', genericAddCommentFailureMessage: 'Error inesperado al añadir el comentario. Por favor, inténtalo más tarde', + genericUpdateReportFieldFailureMessage: 'Error inesperado al actualizar el campo. Por favor, inténtalo más tarde', + genericUpdateReporNameEditFailureMessage: 'Error inesperado al cambiar el nombre del informe. Vuelva a intentarlo más tarde.', noActivityYet: 'Sin actividad todavía', }, chronos: { diff --git a/src/libs/API/parameters/SetReportFieldParams.ts b/src/libs/API/parameters/SetReportFieldParams.ts new file mode 100644 index 000000000000..8b6c8682d657 --- /dev/null +++ b/src/libs/API/parameters/SetReportFieldParams.ts @@ -0,0 +1,6 @@ +type SetReportFieldParams = { + reportID: string; + reportFields: string; +}; + +export default SetReportFieldParams; diff --git a/src/libs/API/parameters/SetReportNameParams.ts b/src/libs/API/parameters/SetReportNameParams.ts new file mode 100644 index 000000000000..784674e1486e --- /dev/null +++ b/src/libs/API/parameters/SetReportNameParams.ts @@ -0,0 +1,6 @@ +type SetReportNameParams = { + reportID: string; + reportName: string; +}; + +export default SetReportNameParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 039398c0fbf6..8c0c2fde17cf 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -122,3 +122,5 @@ export type {default as ReopenTaskParams} from './ReopenTaskParams'; export type {default as CompleteTaskParams} from './CompleteTaskParams'; export type {default as CompleteEngagementModalParams} from './CompleteEngagementModalParams'; export type {default as SetNameValuePairParams} from './SetNameValuePairParams'; +export type {default as SetReportFieldParams} from './SetReportFieldParams'; +export type {default as SetReportNameParams} from './SetReportNameParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f58ebc30b4a2..05b658ee0702 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -113,6 +113,8 @@ const WRITE_COMMANDS = { COMPLETE_TASK: 'CompleteTask', COMPLETE_ENGAGEMENT_MODAL: 'CompleteEngagementModal', SET_NAME_VALUE_PAIR: 'SetNameValuePair', + SET_REPORT_FIELD: 'Report_SetFields', + SET_REPORT_NAME: 'RenameReport', } as const; type WriteCommand = ValueOf; @@ -223,6 +225,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.COMPLETE_TASK]: Parameters.CompleteTaskParams; [WRITE_COMMANDS.COMPLETE_ENGAGEMENT_MODAL]: Parameters.CompleteEngagementModalParams; [WRITE_COMMANDS.SET_NAME_VALUE_PAIR]: Parameters.SetNameValuePairParams; + [WRITE_COMMANDS.SET_REPORT_FIELD]: Parameters.SetReportFieldParams; + [WRITE_COMMANDS.SET_REPORT_NAME]: Parameters.SetReportNameParams; }; const READ_COMMANDS = { diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index dde9cf28148a..9cb08556f082 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -256,7 +256,7 @@ function getZoneAbbreviation(datetime: string | Date, selectedTimezone: Selected * * @returns Sunday, July 9, 2023 */ -function formatToLongDateWithWeekday(datetime: string): string { +function formatToLongDateWithWeekday(datetime: string | Date): string { return format(new Date(datetime), CONST.DATE.LONG_DATE_FORMAT_WITH_WEEKDAY); } diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 78cde95fb0e4..071cf0050688 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -195,7 +195,15 @@ function getEffectiveDisplayName(personalDetail?: PersonalDetails): string | und return undefined; } +/** + * Whether personal details is empty + */ +function isPersonalDetailsEmpty() { + return !personalDetails.length; +} + export { + isPersonalDetailsEmpty, getDisplayNameOrDefault, getPersonalDetailsByIDs, getAccountIDsByLogins, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 228db29aea6c..3d21911c83e8 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14,7 +14,20 @@ import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Beta, PersonalDetails, PersonalDetailsList, Policy, PolicyReportField, Report, ReportAction, ReportMetadata, Session, Transaction, TransactionViolation} from '@src/types/onyx'; +import type { + Beta, + PersonalDetails, + PersonalDetailsList, + Policy, + PolicyReportField, + PolicyReportFields, + Report, + ReportAction, + ReportMetadata, + Session, + Transaction, + TransactionViolation, +} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ChangeLog, IOUMessage, OriginalMessageActionName, OriginalMessageCreated, PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -439,8 +452,21 @@ Onyx.connect({ callback: (value) => (allPolicies = value), }); -let allTransactions: OnyxCollection = {}; +let allPolicyReportFields: OnyxCollection = {}; + +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS, + waitForCollectionCallback: true, + callback: (value) => (allPolicyReportFields = value), +}); +let allBetas: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.BETAS, + callback: (value) => (allBetas = value), +}); + +let allTransactions: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, @@ -1850,10 +1876,74 @@ function getPolicyExpenseChatName(report: OnyxEntry, policy: OnyxEntry

): boolean { + return reportField?.type === 'formula' && reportField?.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; +} + +/** + * Given a report field, check if the field can be edited or not. + * For title fields, its considered disabled if `deletable` prop is `true` (https://github.com/Expensify/App/issues/35043#issuecomment-1911275433) + * For non title fields, its considered disabled if: + * 1. The user is not admin of the report + * 2. Report is settled or it is closed + */ +function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry, policy: OnyxEntry): boolean { + const isReportSettled = isSettled(report?.reportID); + const isReportClosed = report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; + const isTitleField = isReportFieldOfTypeTitle(reportField); + const isAdmin = isPolicyAdmin(report?.policyID ?? '', {[`${ONYXKEYS.COLLECTION.POLICY}${policy?.id ?? ''}`]: policy}); + return isTitleField ? !reportField?.deletable : !isAdmin && (isReportSettled || isReportClosed); +} + +/** + * Given a set of report fields, return the field of type formula + */ +function getFormulaTypeReportField(reportFields: PolicyReportFields) { + return Object.values(reportFields).find((field) => field.type === 'formula'); +} + +/** + * Get the report fields attached to the policy given policyID + */ +function getReportFieldsByPolicyID(policyID: string) { + return Object.entries(allPolicyReportFields ?? {}).find(([key]) => key.replace(ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS, '') === policyID)?.[1]; +} + +/** + * Get the report fields that we should display a MoneyReportView gets opened + */ + +function getAvailableReportFields(report: Report, policyReportFields: PolicyReportField[]): PolicyReportField[] { + // Get the report fields that are attached to a report. These will persist even if a field is deleted from the policy. + const reportFields = Object.values(report.reportFields ?? {}); + const reportIsSettled = isSettled(report.reportID); + + // If the report is settled, we don't want to show any new field that gets added to the policy. + if (reportIsSettled) { + return reportFields; + } + + // If the report is unsettled, we want to merge the new fields that get added to the policy with the fields that + // are attached to the report. + const mergedFieldIds = Array.from(new Set([...policyReportFields.map(({fieldID}) => fieldID), ...reportFields.map(({fieldID}) => fieldID)])); + return mergedFieldIds.map((id) => report?.reportFields?.[id] ?? policyReportFields.find(({fieldID}) => fieldID === id)) as PolicyReportField[]; +} + /** * Get the title for an IOU or expense chat which will be showing the payer and the amount */ function getMoneyRequestReportName(report: OnyxEntry, policy: OnyxEntry | undefined = undefined): string { + const isReportSettled = isSettled(report?.reportID ?? ''); + const reportFields = isReportSettled ? report?.reportFields : getReportFieldsByPolicyID(report?.policyID ?? ''); + const titleReportField = getFormulaTypeReportField(reportFields ?? {}); + + if (titleReportField && report?.reportName && Permissions.canUseReportFields(allBetas ?? [])) { + return report.reportName; + } + const moneyRequestTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency, hasOnlyDistanceRequestTransactions(report?.reportID)); const payerOrApproverName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report?.managerID) ?? ''; @@ -4538,32 +4628,6 @@ function navigateToPrivateNotes(report: Report, session: Session) { Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID)); } -/** - * Given a report field and a report, get the title of the field. - * This is specially useful when we have a report field of type formula. - */ -function getReportFieldTitle(report: OnyxEntry, reportField: PolicyReportField): string { - const value = report?.reportFields?.[reportField.fieldID] ?? reportField.defaultValue; - - if (reportField.type !== 'formula') { - return value; - } - - return value.replaceAll(CONST.REGEX.REPORT_FIELD_TITLE, (match, property) => { - if (report && property in report) { - return report[property as keyof Report]?.toString() ?? match; - } - return match; - }); -} - -/** - * Given a report field, check if the field is for the report title. - */ -function isReportFieldOfTypeTitle(reportField: PolicyReportField): boolean { - return reportField.type === 'formula' && reportField.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; -} - /** * Checks if thread replies should be displayed */ @@ -4790,7 +4854,6 @@ export { canEditWriteCapability, hasSmartscanError, shouldAutoFocusOnKeyPress, - getReportFieldTitle, shouldDisplayThreadReplies, shouldDisableThread, doesReportBelongToWorkspace, @@ -4798,6 +4861,8 @@ export { isReportParticipant, isValidReport, isReportFieldOfTypeTitle, + isReportFieldDisabled, + getAvailableReportFields, }; export type { diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 9119907e9393..066f6fa65bd1 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -373,7 +373,7 @@ function getOptionData({ ? Localize.translate(preferredLocale, 'workspace.invite.invited') : Localize.translate(preferredLocale, 'workspace.invite.removed'); const users = Localize.translate(preferredLocale, targetAccountIDs.length > 1 ? 'workspace.invite.users' : 'workspace.invite.user'); - result.alternateText = `${verb} ${targetAccountIDs.length} ${users}`; + result.alternateText = `${lastActorDisplayName} ${verb} ${targetAccountIDs.length} ${users}`.trim(); const roomName = lastAction?.originalMessage?.roomName ?? ''; if (roomName) { diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index a9e1b09ed984..d5095a19b1ed 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -349,7 +349,7 @@ function getOutstandingChildRequest(policy, needsToBeManuallySubmitted) { * @param {Array} optimisticPolicyRecentlyUsedCategories * @param {Array} optimisticPolicyRecentlyUsedTags * @param {boolean} isNewChatReport - * @param {boolean} isNewIOUReport + * @param {boolean} shouldCreateNewMoneyRequestReport * @param {Object} policy - May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) * @param {Array} policyTags * @param {Array} policyCategories @@ -368,7 +368,7 @@ function buildOnyxDataForMoneyRequest( optimisticPolicyRecentlyUsedCategories, optimisticPolicyRecentlyUsedTags, isNewChatReport, - isNewIOUReport, + shouldCreateNewMoneyRequestReport, policy, policyTags, policyCategories, @@ -391,14 +391,14 @@ function buildOnyxDataForMoneyRequest( }, }, { - onyxMethod: isNewIOUReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + onyxMethod: shouldCreateNewMoneyRequestReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, value: { ...iouReport, lastMessageText: iouAction.message[0].text, lastMessageHtml: iouAction.message[0].html, pendingFields: { - ...(isNewIOUReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(shouldCreateNewMoneyRequestReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, }, }, @@ -416,10 +416,10 @@ function buildOnyxDataForMoneyRequest( }, }, { - onyxMethod: isNewIOUReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + onyxMethod: shouldCreateNewMoneyRequestReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { - ...(isNewIOUReport ? {[iouCreatedAction.reportActionID]: iouCreatedAction} : {}), + ...(shouldCreateNewMoneyRequestReport ? {[iouCreatedAction.reportActionID]: iouCreatedAction} : {}), [iouAction.reportActionID]: iouAction, }, }, @@ -507,7 +507,7 @@ function buildOnyxDataForMoneyRequest( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { - ...(isNewIOUReport + ...(shouldCreateNewMoneyRequestReport ? { [iouCreatedAction.reportActionID]: { pendingAction: null, @@ -547,7 +547,7 @@ function buildOnyxDataForMoneyRequest( value: { pendingFields: null, errorFields: { - ...(isNewIOUReport ? {createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage')} : {}), + ...(shouldCreateNewMoneyRequestReport ? {createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage')} : {}), }, }, }, @@ -593,7 +593,7 @@ function buildOnyxDataForMoneyRequest( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { - ...(isNewIOUReport + ...(shouldCreateNewMoneyRequestReport ? { [iouCreatedAction.reportActionID]: { errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt.filename, isScanRequest), @@ -634,7 +634,7 @@ function buildOnyxDataForMoneyRequest( * Gathers all the data needed to make a money request. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then * it creates optimistic versions of them and uses those instead * - * @param {Object} report + * @param {Object} parentChatReport * @param {Object} participant * @param {String} comment * @param {Number} amount @@ -651,6 +651,7 @@ function buildOnyxDataForMoneyRequest( * @param {Object} [policy] * @param {Object} [policyTags] * @param {Object} [policyCategories] + * @param {Number} [moneyRequestReportID] - If user requests money via the report composer on some money request report, we always add a request to that specific report. * @returns {Object} data * @returns {String} data.payerEmail * @returns {Object} data.iouReport @@ -666,7 +667,7 @@ function buildOnyxDataForMoneyRequest( * @returns {Object} data.onyxData.failureData */ function getMoneyRequestInformation( - report, + parentChatReport, participant, comment, amount, @@ -683,6 +684,7 @@ function getMoneyRequestInformation( policy = undefined, policyTags = undefined, policyCategories = undefined, + moneyRequestReportID = 0, ) { const payerEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login); const payerAccountID = Number(participant.accountID); @@ -690,7 +692,7 @@ function getMoneyRequestInformation( // STEP 1: Get existing chat report OR build a new optimistic one let isNewChatReport = false; - let chatReport = lodashGet(report, 'reportID', null) ? report : null; + let chatReport = lodashGet(parentChatReport, 'reportID', null) ? parentChatReport : null; // If this is a policyExpenseChat, the chatReport must exist and we can get it from Onyx. // report is null if the flow is initiated from the global create menu. However, participant always stores the reportID if it exists, which is the case for policyExpenseChats @@ -708,9 +710,15 @@ function getMoneyRequestInformation( chatReport = ReportUtils.buildOptimisticChatReport([payerAccountID]); } - // STEP 2: Get existing IOU report and update its total OR build a new optimistic one - const isNewIOUReport = !chatReport.iouReportID || ReportUtils.hasIOUWaitingOnCurrentUserBankAccount(chatReport); - let iouReport = isNewIOUReport ? null : allReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`]; + // STEP 2: Get the money request report. If the moneyRequestReportID has been provided, we want to add the transaction to this specific report. + // If no such reportID has been provided, let's use the chatReport.iouReportID property. In case that is not present, build a new optimistic money request report. + let iouReport = null; + const shouldCreateNewMoneyRequestReport = !moneyRequestReportID && (!chatReport.iouReportID || ReportUtils.hasIOUWaitingOnCurrentUserBankAccount(chatReport)); + if (moneyRequestReportID > 0) { + iouReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`]; + } else if (!shouldCreateNewMoneyRequestReport) { + iouReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`]; + } // Check if the Scheduled Submit is enabled in case of expense report let needsToBeManuallySubmitted = true; @@ -719,7 +727,7 @@ function getMoneyRequestInformation( isFromPaidPolicy = PolicyUtils.isPaidGroupPolicy(policy); // If the scheduled submit is turned off on the policy, user needs to manually submit the report which is indicated by GBR in LHN - needsToBeManuallySubmitted = isFromPaidPolicy && !(policy.isHarvestingEnabled || false); + needsToBeManuallySubmitted = isFromPaidPolicy && !(lodashGet(policy, 'harvesting.enabled', policy.isHarvestingEnabled) || false); // If the linked expense report on paid policy is not draft, we need to create a new draft expense report if (iouReport && isFromPaidPolicy && !ReportUtils.isDraftExpenseReport(iouReport)) { @@ -807,7 +815,7 @@ function getMoneyRequestInformation( currentTime, ); - let reportPreviewAction = isNewIOUReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID); + let reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID); if (reportPreviewAction) { reportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); } else { @@ -845,7 +853,7 @@ function getMoneyRequestInformation( optimisticPolicyRecentlyUsedCategories, optimisticPolicyRecentlyUsedTags, isNewChatReport, - isNewIOUReport, + shouldCreateNewMoneyRequestReport, policy, policyTags, policyCategories, @@ -860,7 +868,7 @@ function getMoneyRequestInformation( transaction: optimisticTransaction, iouAction, createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : 0, - createdIOUReportActionID: isNewIOUReport ? optimisticCreatedActionForIOU.reportActionID : 0, + createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOU.reportActionID : 0, reportPreviewAction, onyxData: { optimisticData, @@ -892,6 +900,7 @@ function createDistanceRequest(report, participant, comment, created, category, // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report; + const moneyRequestReportID = isMoneyRequestReport ? report.reportID : 0; const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const optimisticReceipt = { @@ -916,6 +925,7 @@ function createDistanceRequest(report, participant, comment, created, category, policy, policyTags, policyCategories, + moneyRequestReportID, ); API.write( 'CreateDistanceRequest', @@ -1313,6 +1323,7 @@ function requestMoney( // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report; + const moneyRequestReportID = isMoneyRequestReport ? report.reportID : 0; const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const {payerAccountID, payerEmail, iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = getMoneyRequestInformation( @@ -1333,6 +1344,7 @@ function requestMoney( policy, policyTags, policyCategories, + moneyRequestReportID, ); const activeReportID = isMoneyRequestReport ? report.reportID : chatReport.reportID; @@ -1389,10 +1401,11 @@ function requestMoney( * @param {String} category * @param {String} tag * @param {String} existingSplitChatReportID - the report ID where the split bill happens, could be a group chat or a workspace chat + * @param {Boolean} billable * * @return {Object} */ -function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, existingSplitChatReportID = '') { +function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, existingSplitChatReportID = '', billable = false) { const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin); const participantAccountIDs = _.map(participants, (participant) => Number(participant.accountID)); const existingSplitChatReport = @@ -1416,6 +1429,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco undefined, category, tag, + billable, ); // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat @@ -1617,6 +1631,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco undefined, category, tag, + billable, ); // STEP 4: Build optimistic reportActions. We need: @@ -1734,8 +1749,9 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco * @param {String} category * @param {String} tag * @param {String} existingSplitChatReportID - Either a group DM or a workspace chat + * @param {Boolean} billable */ -function splitBill(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, existingSplitChatReportID = '') { +function splitBill(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, existingSplitChatReportID = '', billable = false) { const {splitData, splits, onyxData} = createSplitsAndOnyxData( participants, currentUserLogin, @@ -1747,6 +1763,7 @@ function splitBill(participants, currentUserLogin, currentUserAccountID, amount, category, tag, existingSplitChatReportID, + billable, ); API.write( 'SplitBill', @@ -1759,6 +1776,7 @@ function splitBill(participants, currentUserLogin, currentUserAccountID, amount, category, merchant, tag, + billable, transactionID: splitData.transactionID, reportActionID: splitData.reportActionID, createdReportActionID: splitData.createdReportActionID, @@ -1782,9 +1800,10 @@ function splitBill(participants, currentUserLogin, currentUserAccountID, amount, * @param {String} merchant * @param {String} category * @param {String} tag + * @param {Boolean} billable */ -function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag) { - const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag); +function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, billable) { + const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, billable); API.write( 'SplitBillAndOpenReport', @@ -1797,6 +1816,7 @@ function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccou comment, category, tag, + billable, transactionID: splitData.transactionID, reportActionID: splitData.reportActionID, createdReportActionID: splitData.createdReportActionID, @@ -1821,8 +1841,9 @@ function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccou * @param {String} tag * @param {Object} receipt * @param {String} existingSplitChatReportID - Either a group DM or a workspace chat + * @param {Boolean} billable */ -function startSplitBill(participants, currentUserLogin, currentUserAccountID, comment, category, tag, receipt, existingSplitChatReportID = '') { +function startSplitBill(participants, currentUserLogin, currentUserAccountID, comment, category, tag, receipt, existingSplitChatReportID = '', billable = false) { const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin); const participantAccountIDs = _.map(participants, (participant) => Number(participant.accountID)); const existingSplitChatReport = @@ -1850,6 +1871,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co undefined, category, tag, + billable, ); // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat @@ -2063,6 +2085,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co category, tag, isFromGroupDM: !existingSplitChatReport, + billable, ...(existingSplitChatReport ? {} : {createdReportActionID: splitChatCreatedReportAction.reportActionID}), }, {optimisticData, successData, failureData}, @@ -3752,6 +3775,15 @@ function navigateToStartStepIfScanFileCannotBeRead(receiptFilename, receiptPath, FileUtils.readFileAsync(receiptPath, receiptFilename, onSuccess, onFailure); } +/** + * Save the preferred payment method for a policy + * @param {String} policyID + * @param {String} paymentMethod + */ +function savePreferredPaymentMethod(policyID, paymentMethod) { + Onyx.merge(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {[policyID]: paymentMethod}); +} + export { setMoneyRequestParticipants, createDistanceRequest, @@ -3812,4 +3844,5 @@ export { getIOUReportID, editMoneyRequest, navigateToStartStepIfScanFileCannotBeRead, + savePreferredPaymentMethod, }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 1f6905cfb8e0..221c7f8d4f88 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -63,7 +63,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, PersonalDetailsList, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx'; +import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx'; import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import type {NotificationPreference, WriteCapability} from '@src/types/onyx/Report'; import type Report from '@src/types/onyx/Report'; @@ -175,6 +175,12 @@ Linking.getInitialURL().then((url) => { reportIDDeeplinkedFromOldDot = reportID; }); +let allRecentlyUsedReportFields: OnyxEntry = {}; +Onyx.connect({ + key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, + callback: (val) => (allRecentlyUsedReportFields = val), +}); + /** Get the private pusher channel name for a Report. */ function getReportChannelName(reportID: string): string { return `${CONST.PUSHER.PRIVATE_REPORT_CHANNEL_PREFIX}${reportID}${CONFIG.PUSHER.SUFFIX}`; @@ -1462,6 +1468,137 @@ function toggleSubscribeToChildReport(childReportID = '0', parentReportAction: P } } +function updateReportName(reportID: string, value: string, previousValue: string) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportName: value, + pendingFields: { + reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportName: previousValue, + pendingFields: { + reportName: null, + }, + errorFields: { + reportName: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReporNameEditFailureMessage'), + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + pendingFields: { + reportName: null, + }, + errorFields: { + reportName: null, + }, + }, + }, + ]; + + const parameters = { + reportID, + reportName: value, + }; + + API.write(WRITE_COMMANDS.SET_REPORT_NAME, parameters, {optimisticData, failureData, successData}); +} + +function updateReportField(reportID: string, reportField: PolicyReportField, previousReportField: PolicyReportField) { + const recentlyUsedValues = allRecentlyUsedReportFields?.[reportField.fieldID] ?? []; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportFields: { + [reportField.fieldID]: reportField, + }, + pendingFields: { + [reportField.fieldID]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + if (reportField.type === 'dropdown') { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, + value: { + [reportField.fieldID]: [...new Set([...recentlyUsedValues, reportField.value])], + }, + }); + } + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportFields: { + [reportField.fieldID]: previousReportField, + }, + pendingFields: { + [reportField.fieldID]: null, + }, + errorFields: { + [reportField.fieldID]: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReportFieldFailureMessage'), + }, + }, + }, + ]; + + if (reportField.type === 'dropdown') { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, + value: { + [reportField.fieldID]: recentlyUsedValues, + }, + }); + } + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + pendingFields: { + [reportField.fieldID]: null, + }, + errorFields: { + [reportField.fieldID]: null, + }, + }, + }, + ]; + + const parameters = { + reportID, + reportFields: JSON.stringify({[reportField.fieldID]: reportField}), + }; + + API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData}); +} + function updateWelcomeMessage(reportID: string, previousValue: string, newValue: string) { // No change needed, navigate back if (previousValue === newValue) { @@ -2721,5 +2858,7 @@ export { getDraftPrivateNote, updateLastVisitTime, clearNewRoomFormError, + updateReportField, + updateReportName, resolveActionableMentionWhisper, }; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index a7aab98f02c6..60c05d0cb677 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -729,7 +729,6 @@ function deleteTask(taskReportID: string, taskTitle: string, originalStateNum: n ], errors: undefined, linkMetadata: [], - reportActionID: '', }; const optimisticReportActions = { [parentReportAction.reportActionID]: optimisticReportAction, @@ -751,8 +750,7 @@ function deleteTask(taskReportID: string, taskTitle: string, originalStateNum: n key: `${ONYXKEYS.COLLECTION.REPORT}${parentReport?.reportID}`, value: { lastMessageText: ReportActionsUtils.getLastVisibleMessage(parentReport?.reportID ?? '', optimisticReportActions as OnyxTypes.ReportActions).lastMessageText ?? '', - lastVisibleActionCreated: - ReportActionsUtils.getLastVisibleAction(parentReport?.reportID ?? '', optimisticReportActions as OnyxTypes.ReportActions)?.childLastVisibleActionCreated ?? 'created', + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(parentReport?.reportID ?? '', optimisticReportActions as OnyxTypes.ReportActions)?.created, }, }, { diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index 47ade872d25a..df71bf47b0ac 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -137,6 +137,7 @@ function DetailsPage(props) { headerTitle={displayName} source={UserUtils.getFullSizeAvatar(details.avatar, details.accountID)} originalFileName={details.originalFileName} + maybeIcon > {({show}) => ( void; + onSubmit: (form: OnyxFormValuesFields) => void; }; -function EditReportFieldDatePage({fieldName, onSubmit, fieldValue, fieldID}: EditReportFieldDatePageProps) { +function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldID}: EditReportFieldDatePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const inputRef = useRef(null); const validate = useCallback( - (values: OnyxFormValuesFields) => { + (value: OnyxFormValuesFields) => { const errors: Errors = {}; - const value = values[fieldID]; - if (typeof value === 'string' && value.trim() === '') { + if (isRequired && value[fieldID].trim() === '') { errors[fieldID] = 'common.error.fieldRequired'; } return errors; }, - [fieldID], + [fieldID, isRequired], ); return ( inputRef.current?.focus()} + onEntryTransitionEnd={() => { + inputRef.current?.focus(); + }} testID={EditReportFieldDatePage.displayName} > void; + onSubmit: (form: Record) => void; +}; + +type EditReportFieldDropdownPageOnyxProps = { + recentlyUsedReportFields: OnyxEntry; }; -function EditReportFieldDropdownPage({fieldName, onSubmit, fieldValue, fieldOptions}: EditReportFieldDropdownPageProps) { +type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps & EditReportFieldDropdownPageOnyxProps; + +function EditReportFieldDropdownPage({fieldName, onSubmit, fieldID, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) { const [searchValue, setSearchValue] = useState(''); const styles = useThemeStyles(); const {getSafeAreaMargins} = useStyleUtils(); const {translate} = useLocalize(); + const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldID] ?? [], [recentlyUsedReportFields, fieldID]); const sections = useMemo(() => { - const filteredOptions = fieldOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase())); + const filteredRecentOptions = recentlyUsedOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase())); + const filteredRestOfOptions = fieldOptions.filter((option) => !filteredRecentOptions.includes(option) && option.toLowerCase().includes(searchValue.toLowerCase())); + return [ { title: translate('common.recents'), shouldShow: true, - data: [], + data: filteredRecentOptions.map((option) => ({ + text: option, + keyForList: option, + searchText: option, + tooltipText: option, + })), }, { title: translate('common.all'), shouldShow: true, - data: filteredOptions.map((option) => ({ + data: filteredRestOfOptions.map((option) => ({ text: option, keyForList: option, searchText: option, @@ -45,7 +70,7 @@ function EditReportFieldDropdownPage({fieldName, onSubmit, fieldValue, fieldOpti })), }, ]; - }, [fieldOptions, searchValue, translate]); + }, [fieldOptions, recentlyUsedOptions, searchValue, translate]); return ( ) => onSubmit({[fieldID]: option.text})} onChangeText={setSearchValue} highlightSelectedOptions isRowMultilineSupported @@ -79,4 +104,8 @@ function EditReportFieldDropdownPage({fieldName, onSubmit, fieldValue, fieldOpti EditReportFieldDropdownPage.displayName = 'EditReportFieldDropdownPage'; -export default EditReportFieldDropdownPage; +export default withOnyx({ + recentlyUsedReportFields: { + key: () => ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, + }, +})(EditReportFieldDropdownPage); diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index d74582708995..5bb53ef9122e 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -1,10 +1,15 @@ -import React, {useEffect} from 'react'; +import Str from 'expensify-common/lib/str'; +import React from 'react'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import type {OnyxFormValuesFields} from '@components/Form/types'; import ScreenWrapper from '@components/ScreenWrapper'; +import Navigation from '@libs/Navigation/Navigation'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as ReportActions from '@src/libs/actions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PolicyReportFields, Report} from '@src/types/onyx'; +import type {Policy, PolicyReportFields, Report} from '@src/types/onyx'; import EditReportFieldDatePage from './EditReportFieldDatePage'; import EditReportFieldDropdownPage from './EditReportFieldDropdownPage'; import EditReportFieldTextPage from './EditReportFieldTextPage'; @@ -15,6 +20,9 @@ type EditReportFieldPageOnyxProps = { /** Policy report fields */ policyReportFields: OnyxEntry; + + /** Policy to which the report belongs to */ + policy: OnyxEntry; }; type EditReportFieldPageProps = EditReportFieldPageOnyxProps & { @@ -34,61 +42,77 @@ type EditReportFieldPageProps = EditReportFieldPageOnyxProps & { }; }; -function EditReportFieldPage({route, report, policyReportFields}: EditReportFieldPageProps) { - const policyReportField = policyReportFields?.[route.params.fieldID]; - const reportFieldValue = report?.reportFields?.[policyReportField?.fieldID ?? '']; - - // Decides whether to allow or disallow editing a money request - useEffect(() => {}, []); - - if (policyReportField) { - if (policyReportField.type === 'text' || policyReportField.type === 'formula') { - return ( - {}} - /> - ); - } +function EditReportFieldPage({route, policy, report, policyReportFields}: EditReportFieldPageProps) { + const reportField = report?.reportFields?.[route.params.fieldID] ?? policyReportFields?.[route.params.fieldID]; + const isDisabled = ReportUtils.isReportFieldDisabled(report, reportField ?? null, policy); - if (policyReportField.type === 'date') { - return ( - {}} + if (!reportField || !report || isDisabled) { + return ( + + {}} + onLinkPress={() => {}} /> - ); - } + + ); + } - if (policyReportField.type === 'dropdown') { - return ( - {}} - /> - ); + const isReportFieldTitle = ReportUtils.isReportFieldOfTypeTitle(reportField); + + const handleReportFieldChange = (form: OnyxFormValuesFields) => { + const value = form[reportField.fieldID] || ''; + if (isReportFieldTitle) { + ReportActions.updateReportName(report.reportID, value, report.reportName ?? ''); + } else { + ReportActions.updateReportField(report.reportID, {...reportField, value}, reportField); } + + Navigation.dismissModal(report?.reportID); + }; + + const fieldValue = isReportFieldTitle ? report.reportName ?? '' : reportField.value ?? reportField.defaultValue; + + if (reportField.type === 'text' || isReportFieldTitle) { + return ( + + ); + } + + if (reportField.type === 'date') { + return ( + + ); } - return ( - - {}} - onLinkPress={() => {}} + if (reportField.type === 'dropdown') { + return ( + - - ); + ); + } } EditReportFieldPage.displayName = 'EditReportFieldPage'; @@ -100,4 +124,7 @@ export default withOnyx( policyReportFields: { key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${route.params.policyID}`, }, + policy: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`, + }, })(EditReportFieldPage); diff --git a/src/pages/EditReportFieldTextPage.tsx b/src/pages/EditReportFieldTextPage.tsx index 733bfd6e5fee..ea9d2d3bed6d 100644 --- a/src/pages/EditReportFieldTextPage.tsx +++ b/src/pages/EditReportFieldTextPage.tsx @@ -23,38 +23,42 @@ type EditReportFieldTextPageProps = { /** ID of the policy report field */ fieldID: string; + /** Flag to indicate if the field can be left blank */ + isRequired: boolean; + /** Callback to fire when the Save button is pressed */ - onSubmit: () => void; + onSubmit: (form: OnyxFormValuesFields) => void; }; -function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, fieldID}: EditReportFieldTextPageProps) { +function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldID}: EditReportFieldTextPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const inputRef = useRef(null); const validate = useCallback( - (values: OnyxFormValuesFields) => { + (values: OnyxFormValuesFields) => { const errors: Errors = {}; - const value = values[fieldID]; - if (typeof value === 'string' && value.trim() === '') { + if (isRequired && values[fieldID].trim() === '') { errors[fieldID] = 'common.error.fieldRequired'; } return errors; }, - [fieldID], + [fieldID, isRequired], ); return ( inputRef.current?.focus()} + onEntryTransitionEnd={() => { + inputRef.current?.focus(); + }} testID={EditReportFieldTextPage.displayName} > ReportUtils.getIcons(props.report, props.personalDetails, props.policies), [props.report, props.personalDetails, props.policies]); + const icons = useMemo(() => ReportUtils.getIcons(props.report, props.personalDetails, null, '', -1, policy), [props.report, props.personalDetails, policy]); const chatRoomSubtitleText = chatRoomSubtitle ? ( _.pick(policy, ['role']), }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, })(HeaderView), ); diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 4d043f12351e..f28e418865ff 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -1,7 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -11,7 +11,6 @@ import DragAndDropProvider from '@components/DragAndDrop/Provider'; import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails} from '@components/OnyxProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; @@ -28,14 +27,13 @@ import Navigation from '@libs/Navigation/Navigation'; import clearReportNotifications from '@libs/Notification/clearReportNotifications'; import reportWithoutHasDraftSelector from '@libs/OnyxSelectors/reportWithoutHasDraftSelector'; import Performance from '@libs/Performance'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportMetadataPropTypes from '@pages/reportMetadataPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; import * as ComposerActions from '@userActions/Composer'; import * as Report from '@userActions/Report'; -import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -93,9 +91,6 @@ const propTypes = { /** The account manager report ID */ accountManagerReportID: PropTypes.string, - /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - /** Onyx function that marks the component ready for hydration */ markReadyForHydration: PropTypes.func, @@ -121,7 +116,6 @@ const defaultProps = { policies: {}, accountManagerReportID: null, userLeavingStatus: false, - personalDetails: {}, markReadyForHydration: null, ...withCurrentReportIDDefaultProps, }; @@ -146,12 +140,11 @@ function getReportID(route) { function ReportScreen({ betas, route, - report, + report: reportProp, reportMetadata, reportActions, parentReportAction, accountManagerReportID, - personalDetails, markReadyForHydration, policies, isSidebarLoaded, @@ -169,6 +162,77 @@ function ReportScreen({ const firstRenderRef = useRef(true); const flatListRef = useRef(); const reactionListRef = useRef(); + /** + * Create a lightweight Report so as to keep the re-rendering as light as possible by + * passing in only the required props. + * + * Also, this plays nicely in contrast with Onyx, + * which creates a new object every time collection changes. Because of this we can't + * put this into onyx selector as it will be the same. + */ + const report = useMemo( + () => ({ + lastReadTime: reportProp.lastReadTime, + reportID: reportProp.reportID, + policyID: reportProp.policyID, + lastVisibleActionCreated: reportProp.lastVisibleActionCreated, + statusNum: reportProp.statusNum, + stateNum: reportProp.stateNum, + writeCapability: reportProp.writeCapability, + type: reportProp.type, + errorFields: reportProp.errorFields, + isPolicyExpenseChat: reportProp.isPolicyExpenseChat, + parentReportID: reportProp.parentReportID, + parentReportActionID: reportProp.parentReportActionID, + chatType: reportProp.chatType, + pendingFields: reportProp.pendingFields, + isDeletedParentAction: reportProp.isDeletedParentAction, + reportName: reportProp.reportName, + description: reportProp.description, + managerID: reportProp.managerID, + total: reportProp.total, + nonReimbursableTotal: reportProp.nonReimbursableTotal, + reportFields: reportProp.reportFields, + ownerAccountID: reportProp.ownerAccountID, + currency: reportProp.currency, + participantAccountIDs: reportProp.participantAccountIDs, + isWaitingOnBankAccount: reportProp.isWaitingOnBankAccount, + iouReportID: reportProp.iouReportID, + isOwnPolicyExpenseChat: reportProp.isOwnPolicyExpenseChat, + notificationPreference: reportProp.notificationPreference, + }), + [ + reportProp.lastReadTime, + reportProp.reportID, + reportProp.policyID, + reportProp.lastVisibleActionCreated, + reportProp.statusNum, + reportProp.stateNum, + reportProp.writeCapability, + reportProp.type, + reportProp.errorFields, + reportProp.isPolicyExpenseChat, + reportProp.parentReportID, + reportProp.parentReportActionID, + reportProp.chatType, + reportProp.pendingFields, + reportProp.isDeletedParentAction, + reportProp.reportName, + reportProp.description, + reportProp.managerID, + reportProp.total, + reportProp.nonReimbursableTotal, + reportProp.reportFields, + reportProp.ownerAccountID, + reportProp.currency, + reportProp.participantAccountIDs, + reportProp.isWaitingOnBankAccount, + reportProp.iouReportID, + reportProp.isOwnPolicyExpenseChat, + reportProp.notificationPreference, + ], + ); + const prevReport = usePrevious(report); const prevUserLeavingStatus = usePrevious(userLeavingStatus); const [isBannerVisible, setIsBannerVisible] = useState(true); @@ -184,12 +248,20 @@ function ReportScreen({ const reportID = getReportID(route); const {addWorkspaceRoomOrChatPendingAction, addWorkspaceRoomOrChatErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; - + const isEmptyChat = useMemo(() => _.isEmpty(reportActions), [reportActions]); // There are no reportActions at all to display and we are still in the process of loading the next set of actions. const isLoadingInitialReportActions = _.isEmpty(reportActions) && reportMetadata.isLoadingInitialReportActions; const isOptimisticDelete = lodashGet(report, 'statusNum') === CONST.REPORT.STATUS_NUM.CLOSED; const shouldHideReport = !ReportUtils.canAccessReport(report, policies, betas); - const isLoading = !reportID || !isSidebarLoaded || _.isEmpty(personalDetails); + + const isLoading = !reportID || !isSidebarLoaded || PersonalDetailsUtils.isPersonalDetailsEmpty(); + const lastReportAction = useMemo( + () => + reportActions.length + ? _.find([...reportActions, parentReportAction], (action) => ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action)) + : {}, + [reportActions, parentReportAction], + ); const isSingleTransactionView = ReportUtils.isMoneyRequest(report); const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] || {}; const isTopMostReportId = currentReportID === getReportID(route); @@ -210,7 +282,6 @@ function ReportScreen({ ); @@ -220,7 +291,6 @@ function ReportScreen({ @@ -232,7 +302,6 @@ function ReportScreen({ ); @@ -286,54 +355,6 @@ function ReportScreen({ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(accountManagerReportID)); }, [accountManagerReportID]); - const allPersonalDetails = usePersonalDetails(); - - /** - * @param {String} text - */ - const handleCreateTask = useCallback( - (text) => { - /** - * Matching task rule by group - * Group 1: Start task rule with [] - * Group 2: Optional email group between \s+....\s* start rule with @+valid email - * Group 3: Title is remaining characters - */ - const taskRegex = /^\[\]\s+(?:@([^\s@]+@[\w.-]+\.[a-zA-Z]{2,}))?\s*([\s\S]*)/; - - const match = text.match(taskRegex); - if (!match) { - return false; - } - const title = match[2] ? match[2].trim().replace(/\n/g, ' ') : undefined; - if (!title) { - return false; - } - const email = match[1] ? match[1].trim() : undefined; - let assignee = {}; - if (email) { - assignee = _.find(_.values(allPersonalDetails), (p) => p.login === email) || {}; - } - Task.createTaskAndNavigate(getReportID(route), title, '', assignee.login, assignee.accountID, assignee.assigneeChatReport, report.policyID); - return true; - }, - [allPersonalDetails, report.policyID, route], - ); - - /** - * @param {String} text - */ - const onSubmitComment = useCallback( - (text) => { - const isTaskCreated = handleCreateTask(text); - if (isTaskCreated) { - return; - } - Report.addComment(getReportID(route), text); - }, - [route, handleCreateTask], - ); - // Clear notifications for the current report when it's opened and re-focused const clearNotifications = useCallback(() => { // Check if this is the top-most ReportScreen since the Navigator preserves multiple at a time @@ -538,14 +559,12 @@ function ReportScreen({ {isReportReadyForDisplay ? ( ) : ( @@ -604,9 +623,6 @@ export default compose( key: ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, initialValue: null, }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, userLeavingStatus: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`, initialValue: false, @@ -625,4 +641,23 @@ export default compose( }, true, ), -)(ReportScreen); +)( + memo( + ReportScreen, + (prevProps, nextProps) => + prevProps.isSidebarLoaded === nextProps.isSidebarLoaded && + _.isEqual(prevProps.reportActions, nextProps.reportActions) && + _.isEqual(prevProps.reportMetadata, nextProps.reportMetadata) && + prevProps.isComposerFullSize === nextProps.isComposerFullSize && + _.isEqual(prevProps.betas, nextProps.betas) && + _.isEqual(prevProps.policies, nextProps.policies) && + prevProps.accountManagerReportID === nextProps.accountManagerReportID && + prevProps.userLeavingStatus === nextProps.userLeavingStatus && + prevProps.report.reportID === nextProps.report.reportID && + prevProps.report.policyID === nextProps.report.policyID && + prevProps.report.isOptimisticReport === nextProps.report.isOptimisticReport && + prevProps.report.statusNum === nextProps.report.statusNum && + _.isEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) && + prevProps.currentReportID === nextProps.currentReportID, + ), +); diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 444dd939142b..df5645ae61ad 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -59,7 +59,7 @@ const propTypes = { isBlockedFromConcierge: PropTypes.bool.isRequired, /** Whether or not the attachment picker is disabled */ - disabled: PropTypes.bool.isRequired, + disabled: PropTypes.bool, /** Sets the menu visibility */ setMenuVisibility: PropTypes.func.isRequired, @@ -100,6 +100,7 @@ const propTypes = { const defaultProps = { reportParticipantIDs: [], + disabled: false, policy: {}, }; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js index 90c2ba0b42cf..4b5a54a6c428 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js @@ -1,6 +1,6 @@ import {useIsFocused, useNavigation} from '@react-navigation/native'; import lodashGet from 'lodash/get'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {findNodeHandle, InteractionManager, NativeModules, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -74,8 +74,10 @@ function ComposerWithSuggestions({ isKeyboardShown, // Props: Report reportID, - report, - reportActions, + includeChronos, + isEmptyChat, + lastReportAction, + parentReportActionID, // Focus onFocus, onBlur, @@ -114,12 +116,13 @@ function ComposerWithSuggestions({ const isFocused = useIsFocused(); const navigation = useNavigation(); const emojisPresentBefore = useRef([]); + + const draftComment = getDraftComment(reportID) || ''; const [value, setValue] = useState(() => { - const draft = getDraftComment(reportID) || ''; - if (draft) { - emojisPresentBefore.current = EmojiUtils.extractEmojis(draft); + if (draftComment) { + emojisPresentBefore.current = EmojiUtils.extractEmojis(draftComment); } - return draft; + return draftComment; }); const commentRef = useRef(value); const lastTextRef = useRef(value); @@ -127,8 +130,7 @@ function ComposerWithSuggestions({ const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]); - const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]); + const parentReportAction = lodashGet(parentReportActions, [parentReportActionID]); const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && shouldShowComposeInput; const valueRef = useRef(value); @@ -384,18 +386,14 @@ function ComposerWithSuggestions({ // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants const valueLength = valueRef.current.length; - if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !ReportUtils.chatIncludesChronos(report)) { + if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !includeChronos) { e.preventDefault(); - const lastReportAction = _.find( - [...reportActions, parentReportAction], - (action) => ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action), - ); if (lastReportAction) { Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html); } } }, - [isKeyboardShown, isSmallScreenWidth, parentReportAction, report, reportActions, reportID, handleSendMessage, suggestionsRef, valueRef], + [isSmallScreenWidth, isKeyboardShown, suggestionsRef, includeChronos, handleSendMessage, lastReportAction, reportID], ); const onChangeText = useCallback( @@ -574,6 +572,22 @@ function ComposerWithSuggestions({ onValueChange(value); }, [onValueChange, value]); + const onLayout = useCallback( + (e) => { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { + return; + } + setComposerHeight(composerLayoutHeight); + }, + [composerHeight], + ); + + const onClear = useCallback(() => { + setTextInputShouldClear(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( <> @@ -594,7 +608,7 @@ function ComposerWithSuggestions({ onClick={setShouldBlockSuggestionCalcToFalse} onPasteFile={displayFileInModal} shouldClear={textInputShouldClear} - onClear={() => setTextInputShouldClear(false)} + onClear={onClear} isDisabled={isBlockedFromConcierge || disabled} isReportActionCompose selection={selection} @@ -607,13 +621,7 @@ function ComposerWithSuggestions({ numberOfLines={numberOfLines} onNumberOfLinesChange={updateNumberOfLines} shouldCalculateCaretPosition - onLayout={(e) => { - const composerLayoutHeight = e.nativeEvent.layout.height; - if (composerHeight === composerLayoutHeight) { - return; - } - setComposerHeight(composerLayoutHeight); - }} + onLayout={onLayout} onScroll={hideSuggestionMenu} shouldContainScroll={Browser.isMobileSafari()} /> @@ -637,7 +645,6 @@ function ComposerWithSuggestions({ `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, + key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, canEvict: false, initWithStoredValues: false, }, }), -)(ComposerWithSuggestionsWithRef); +)(memo(ComposerWithSuggestionsWithRef)); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js index e2aa1d86af03..9d05db572949 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js @@ -1,5 +1,4 @@ import PropTypes from 'prop-types'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import CONST from '@src/CONST'; const propTypes = { @@ -18,20 +17,9 @@ const propTypes = { /** Whether the keyboard is open or not */ isKeyboardShown: PropTypes.bool.isRequired, - /** The actions from the parent report */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), - - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - /** The ID of the report */ reportID: PropTypes.string.isRequired, - /** The report currently being looked at */ - report: PropTypes.shape({ - parentReportID: PropTypes.string, - }).isRequired, - /** Callback when the input is focused */ onFocus: PropTypes.func.isRequired, @@ -60,7 +48,7 @@ const propTypes = { isBlockedFromConcierge: PropTypes.bool.isRequired, /** Whether the input is disabled or not */ - disabled: PropTypes.bool.isRequired, + disabled: PropTypes.bool, /** Whether the full composer is available or not */ isFullComposerAvailable: PropTypes.bool.isRequired, @@ -121,6 +109,7 @@ const defaultProps = { reportActions: [], forwardedRef: null, measureParentContainer: () => {}, + disabled: false, }; export {propTypes, defaultProps}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index c52b8ec6760a..cc07716209a2 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -1,7 +1,7 @@ import {PortalHost} from '@gorhom/portal'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated'; @@ -26,7 +26,6 @@ import getModalState from '@libs/getModalState'; import * as ReportUtils from '@libs/ReportUtils'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import ReportDropUI from '@pages/home/report/ReportDropUI'; import ReportTypingIndicator from '@pages/home/report/ReportTypingIndicator'; import reportPropTypes from '@pages/reportPropTypes'; @@ -46,9 +45,6 @@ const propTypes = { /** The ID of the report actions will be created for */ reportID: PropTypes.string.isRequired, - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - /** The report currently being looked at */ report: reportPropTypes, @@ -106,7 +102,8 @@ function ReportActionCompose({ pendingAction, report, reportID, - reportActions, + isEmptyChat, + lastReportAction, listHeight, shouldShowComposeInput, isReportReadyForDisplay, @@ -117,6 +114,7 @@ function ReportActionCompose({ const animatedRef = useAnimatedRef(); const actionButtonRef = useRef(null); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; + /** * Updates the Highlight state of the composer */ @@ -180,7 +178,9 @@ function ReportActionCompose({ [personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize], ); - const isBlockedFromConcierge = useMemo(() => ReportUtils.chatIncludesConcierge(report) && User.isBlockedFromConcierge(blockedFromConcierge), [report, blockedFromConcierge]); + const includesConcierge = useMemo(() => ReportUtils.chatIncludesConcierge({participantAccountIDs: report.participantAccountIDs}), [report.participantAccountIDs]); + const userBlockedFromConcierge = useMemo(() => User.isBlockedFromConcierge(blockedFromConcierge), [blockedFromConcierge]); + const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]); // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions const conciergePlaceholderRandomIndex = useMemo( @@ -191,8 +191,8 @@ function ReportActionCompose({ // Placeholder to display in the chat input. const inputPlaceholder = useMemo(() => { - if (ReportUtils.chatIncludesConcierge(report)) { - if (User.isBlockedFromConcierge(blockedFromConcierge)) { + if (includesConcierge) { + if (userBlockedFromConcierge) { return translate('reportActionCompose.blockedFromConcierge'); } @@ -200,7 +200,7 @@ function ReportActionCompose({ } return translate('reportActionCompose.writeSomething'); - }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]); + }, [includesConcierge, translate, userBlockedFromConcierge, conciergePlaceholderRandomIndex]); const focus = () => { if (composerRef === null || composerRef.current === null) { @@ -421,8 +421,11 @@ function ReportActionCompose({ isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered} raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered} reportID={reportID} - report={report} - reportActions={reportActions} + parentReportID={report.parentReportID} + parentReportActionID={report.parentReportActionID} + includesChronos={ReportUtils.chatIncludesChronos(report)} + isEmptyChat={isEmptyChat} + lastReportAction={lastReportAction} isMenuVisible={isMenuVisible} inputPlaceholder={inputPlaceholder} isComposerFullSize={isComposerFullSize} @@ -501,4 +504,4 @@ export default compose( key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, }, }), -)(ReportActionCompose); +)(memo(ReportActionCompose)); diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js index d0b0453ace2f..e9e3ef244f9c 100644 --- a/src/pages/home/report/ReportActionCompose/SendButton.js +++ b/src/pages/home/report/ReportActionCompose/SendButton.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, {memo} from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Icon from '@components/Icon'; @@ -63,4 +63,4 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}) { SendButton.propTypes = propTypes; SendButton.displayName = 'SendButton'; -export default SendButton; +export default memo(SendButton); diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js index 9aa997a892f4..23d69ec7defc 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js @@ -9,12 +9,6 @@ const propTypes = { /** The comment of the report */ comment: PropTypes.string, - /** The report associated with the comment */ - report: PropTypes.shape({ - /** The ID of the report */ - reportID: PropTypes.string, - }).isRequired, - /** The value of the comment */ value: PropTypes.string.isRequired, @@ -26,6 +20,8 @@ const propTypes = { /** Updates the comment */ updateComment: PropTypes.func.isRequired, + + reportID: PropTypes.string.isRequired, }; const defaultProps = { @@ -38,9 +34,9 @@ const defaultProps = { * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. * @returns {null} */ -function SilentCommentUpdater({comment, commentRef, report, value, updateComment}) { +function SilentCommentUpdater({comment, commentRef, reportID, value, updateComment}) { const prevCommentProp = usePrevious(comment); - const prevReportId = usePrevious(report.reportID); + const prevReportId = usePrevious(reportID); const {preferredLocale} = useLocalize(); const prevPreferredLocale = usePrevious(preferredLocale); @@ -56,12 +52,12 @@ function SilentCommentUpdater({comment, commentRef, report, value, updateComment // As the report IDs change, make sure to update the composer comment as we need to make sure // we do not show incorrect data in there (ie. draft of message from other report). - if (preferredLocale === prevPreferredLocale && report.reportID === prevReportId && !shouldSyncComment) { + if (preferredLocale === prevPreferredLocale && reportID === prevReportId && !shouldSyncComment) { return; } updateComment(comment); - }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value, commentRef]); + }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, reportID, updateComment, value, commentRef]); return null; } diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 141b2b8a5a6d..c290c9378e65 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -107,7 +107,6 @@ const propTypes = { /** Stores user's preferred skin tone */ preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - ...windowDimensionsPropTypes, emojiReactions: EmojiReactionsPropTypes, /** IOU report for this action, if any */ @@ -150,7 +149,7 @@ function ReportActionItem(props) { const prevDraftMessage = usePrevious(props.draftMessage); const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action); const originalReport = props.report.reportID === originalReportID ? props.report : ReportUtils.getReport(originalReportID); - const isReportActionLinked = props.linkedReportActionID === props.action.reportActionID; + const isReportActionLinked = props.linkedReportActionID && props.action.reportActionID && props.linkedReportActionID === props.action.reportActionID; const highlightedBackgroundColorIfNeeded = useMemo( () => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(theme.hoverComponentBG) : {}), @@ -509,7 +508,6 @@ function ReportActionItem(props) { reportID={props.report.reportID} index={props.index} ref={textInputRef} - report={props.report} // Avoid defining within component due to an existing Onyx bug preferredSkinTone={props.preferredSkinTone} shouldDisableEmojiPicker={ @@ -652,6 +650,7 @@ function ReportActionItem(props) { @@ -807,6 +806,10 @@ export default compose( key: ({report}) => (report && 'policyID' in report ? `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID}` : undefined), initialValue: [], }, + policy: { + key: ({report}) => (report && 'policyID' in report ? `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}` : undefined), + initialValue: {}, + }, emojiReactions: { key: ({action}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, initialValue: {}, @@ -850,6 +853,7 @@ export default compose( lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) && prevProps.linkedReportActionID === nextProps.linkedReportActionID && _.isEqual(prevProps.policyReportFields, nextProps.policyReportFields) && - _.isEqual(prevProps.report.reportFields, nextProps.report.reportFields), + _.isEqual(prevProps.report.reportFields, nextProps.report.reportFields) && + _.isEqual(prevProps.policy, nextProps.policy), ), ); diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index 82c6bebd9ba1..f8345ca7d2d0 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -115,6 +115,7 @@ export default withOnyx ({ + reportActionID: reportAction.reportActionID, + message: reportAction.message, + pendingAction: reportAction.pendingAction, + actionName: reportAction.actionName, + errors: reportAction.errors, + originalMessage: reportAction.originalMessage, + childCommenterCount: reportAction.childCommenterCount, + linkMetadata: reportAction.linkMetadata, + childReportID: reportAction.childReportID, + childLastVisibleActionCreated: reportAction.childLastVisibleActionCreated, + whisperedToAccountIDs: reportAction.whisperedToAccountIDs, + error: reportAction.error, + created: reportAction.created, + actorAccountID: reportAction.actorAccountID, + childVisibleActionCount: reportAction.childVisibleActionCount, + childOldestFourAccountIDs: reportAction.childOldestFourAccountIDs, + childType: reportAction.childType, + person: reportAction.person, + isOptimisticAction: reportAction.isOptimisticAction, + delegateAccountID: reportAction.delegateAccountID, + previousMessage: reportAction.previousMessage, + attachmentInfo: reportAction.attachmentInfo, + childStateNum: reportAction.childStateNum, + childStatusNum: reportAction.childStatusNum, + childReportName: reportAction.childReportName, + childManagerAccountID: reportAction.childManagerAccountID, + childMoneyRequestCount: reportAction.childMoneyRequestCount, + }), + [ + reportAction.actionName, + reportAction.childCommenterCount, + reportAction.childLastVisibleActionCreated, + reportAction.childReportID, + reportAction.created, + reportAction.error, + reportAction.errors, + reportAction.linkMetadata, + reportAction.message, + reportAction.originalMessage, + reportAction.pendingAction, + reportAction.reportActionID, + reportAction.whisperedToAccountIDs, + reportAction.actorAccountID, + reportAction.childVisibleActionCount, + reportAction.childOldestFourAccountIDs, + reportAction.person, + reportAction.isOptimisticAction, + reportAction.childType, + reportAction.delegateAccountID, + reportAction.previousMessage, + reportAction.attachmentInfo, + reportAction.childStateNum, + reportAction.childStatusNum, + reportAction.childReportName, + reportAction.childManagerAccountID, + reportAction.childMoneyRequestCount, + ], + ); + return shouldDisplayParentAction ? ( { const wasLoginChangedDetected = prevAuthTokenType === 'anonymousAccount' && !props.session.authTokenType; @@ -159,7 +159,7 @@ function ReportActionsView(props) { // update ref with current state prevIsSmallScreenWidthRef.current = props.isSmallScreenWidth; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isSmallScreenWidth, props.report, props.reportActions, isReportFullyVisible]); + }, [props.isSmallScreenWidth, props.reportActions, isReportFullyVisible]); useEffect(() => { // Ensures subscription event succeeds when the report/workspace room is created optimistically. @@ -171,7 +171,7 @@ function ReportActionsView(props) { Report.subscribeToReportTypingEvents(reportID); didSubscribeToReportTypingEvents.current = true; } - }, [props.report, didSubscribeToReportTypingEvents, reportID]); + }, [props.report.pendingFields, didSubscribeToReportTypingEvents, reportID]); /** * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently @@ -278,14 +278,6 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (!_.isEqual(oldProps.report.pendingFields, newProps.report.pendingFields)) { - return false; - } - - if (!_.isEqual(oldProps.report.errorFields, newProps.report.errorFields)) { - return false; - } - if (lodashGet(oldProps.network, 'isOffline') !== lodashGet(newProps.network, 'isOffline')) { return false; } @@ -306,10 +298,6 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (oldProps.report.lastReadTime !== newProps.report.lastReadTime) { - return false; - } - if (newProps.isSmallScreenWidth !== oldProps.isSmallScreenWidth) { return false; } @@ -318,10 +306,6 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (lodashGet(newProps.report, 'statusNum') !== lodashGet(oldProps.report, 'statusNum') || lodashGet(newProps.report, 'stateNum') !== lodashGet(oldProps.report, 'stateNum')) { - return false; - } - if (lodashGet(newProps, 'policy.avatar') !== lodashGet(oldProps, 'policy.avatar')) { return false; } @@ -330,35 +314,14 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (lodashGet(newProps, 'report.reportName') !== lodashGet(oldProps, 'report.reportName')) { - return false; - } - - if (lodashGet(newProps, 'report.description') !== lodashGet(oldProps, 'report.description')) { - return false; - } - - if (lodashGet(newProps, 'report.managerID') !== lodashGet(oldProps, 'report.managerID')) { - return false; - } - - if (lodashGet(newProps, 'report.total') !== lodashGet(oldProps, 'report.total')) { - return false; - } - - if (lodashGet(newProps, 'report.nonReimbursableTotal') !== lodashGet(oldProps, 'report.nonReimbursableTotal')) { - return false; - } - - if (lodashGet(newProps, 'report.writeCapability') !== lodashGet(oldProps, 'report.writeCapability')) { - return false; - } - - if (lodashGet(newProps, 'report.participantAccountIDs', 0) !== lodashGet(oldProps, 'report.participantAccountIDs', 0)) { - return false; - } - - return _.isEqual(lodashGet(newProps.report, 'icons', []), lodashGet(oldProps.report, 'icons', [])); + return ( + oldProps.report.lastReadTime === newProps.report.lastReadTime && + oldProps.report.reportID === newProps.report.reportID && + oldProps.report.policyID === newProps.report.policyID && + oldProps.report.lastVisibleActionCreated === newProps.report.lastVisibleActionCreated && + oldProps.report.isOptimisticReport === newProps.report.isOptimisticReport && + _.isEqual(oldProps.report.pendingFields, newProps.report.pendingFields) + ); } const MemoizedReportActionsView = React.memo(ReportActionsView, arePropsEqual); diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 48bfd5d18bcc..1761e135481a 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -1,11 +1,12 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, {memo, useCallback} from 'react'; import {Keyboard, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import _, {isEqual} from 'underscore'; import AnonymousReportFooter from '@components/AnonymousReportFooter'; import ArchivedReportFooter from '@components/ArchivedReportFooter'; import OfflineIndicator from '@components/OfflineIndicator'; -import participantPropTypes from '@components/participantPropTypes'; +import {usePersonalDetails} from '@components/OnyxProvider'; import SwipeableView from '@components/SwipeableView'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import useNetwork from '@hooks/useNetwork'; @@ -14,7 +15,9 @@ import compose from '@libs/compose'; import * as ReportUtils from '@libs/ReportUtils'; import reportPropTypes from '@pages/reportPropTypes'; import variables from '@styles/variables'; +import * as Report from '@userActions/Report'; import * as Session from '@userActions/Session'; +import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ReportActionCompose from './ReportActionCompose/ReportActionCompose'; @@ -24,43 +27,33 @@ const propTypes = { /** Report object for the current report */ report: reportPropTypes, - /** Report actions for the current report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + lastReportAction: PropTypes.shape(reportActionPropTypes), - /** Callback fired when the comment is submitted */ - onSubmitComment: PropTypes.func, + isEmptyChat: PropTypes.bool, /** The pending action when we are adding a chat */ pendingAction: PropTypes.string, - /** Personal details of all the users */ - personalDetails: PropTypes.objectOf(participantPropTypes), - - /** Whether the composer input should be shown */ - shouldShowComposeInput: PropTypes.bool, - - /** Whether user interactions should be disabled */ - shouldDisableCompose: PropTypes.bool, - /** Height of the list which the composer is part of */ listHeight: PropTypes.number, /** Whetjer the report is ready for display */ isReportReadyForDisplay: PropTypes.bool, + /** Whether to show the compose input */ + shouldShowComposeInput: PropTypes.bool, + ...windowDimensionsPropTypes, }; const defaultProps = { report: {reportID: '0'}, - reportActions: [], - onSubmitComment: () => {}, pendingAction: null, - personalDetails: {}, - shouldShowComposeInput: true, - shouldDisableCompose: false, listHeight: 0, isReportReadyForDisplay: true, + lastReportAction: null, + isEmptyChat: true, + shouldShowComposeInput: false, }; function ReportFooter(props) { @@ -73,6 +66,52 @@ function ReportFooter(props) { const isSmallSizeLayout = props.windowWidth - (props.isSmallScreenWidth ? 0 : variables.sideBarWidth) < variables.anonymousReportFooterBreakpoint; const hideComposer = !ReportUtils.canUserPerformWriteAction(props.report); + const allPersonalDetails = usePersonalDetails(); + + /** + * @param {String} text + */ + const handleCreateTask = useCallback( + (text) => { + /** + * Matching task rule by group + * Group 1: Start task rule with [] + * Group 2: Optional email group between \s+....\s* start rule with @+valid email + * Group 3: Title is remaining characters + */ + const taskRegex = /^\[\]\s+(?:@([^\s@]+@[\w.-]+\.[a-zA-Z]{2,}))?\s*([\s\S]*)/; + + const match = text.match(taskRegex); + if (!match) { + return false; + } + const title = match[2] ? match[2].trim().replace(/\n/g, ' ') : undefined; + if (!title) { + return false; + } + const email = match[1] ? match[1].trim() : undefined; + let assignee = {}; + if (email) { + assignee = _.find(_.values(allPersonalDetails), (p) => p.login === email) || {}; + } + Task.createTaskAndNavigate(props.report.reportID, title, '', assignee.login, assignee.accountID, assignee.assigneeChatReport, props.report.policyID); + return true; + }, + [allPersonalDetails, props.report.policyID, props.report.reportID], + ); + + const onSubmitComment = useCallback( + (text) => { + const isTaskCreated = handleCreateTask(text); + if (isTaskCreated) { + return; + } + Report.addComment(props.report.reportID, text); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.report.reportID, handleCreateTask], + ); + return ( <> {hideComposer && ( @@ -81,7 +120,6 @@ function ReportFooter(props) { )} {isArchivedRoom && } @@ -94,13 +132,13 @@ function ReportFooter(props) { @@ -122,4 +160,19 @@ export default compose( initialValue: false, }, }), -)(ReportFooter); +)( + memo( + ReportFooter, + (prevProps, nextProps) => + isEqual(prevProps.report, nextProps.report) && + prevProps.pendingAction === nextProps.pendingAction && + prevProps.listHeight === nextProps.listHeight && + prevProps.isComposerFullSize === nextProps.isComposerFullSize && + prevProps.isEmptyChat === nextProps.isEmptyChat && + prevProps.lastReportAction === nextProps.lastReportAction && + prevProps.shouldShowComposeInput === nextProps.shouldShowComposeInput && + prevProps.windowWidth === nextProps.windowWidth && + prevProps.isSmallScreenWidth === nextProps.isSmallScreenWidth && + prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay, + ), +); diff --git a/src/pages/home/report/ReportTypingIndicator.js b/src/pages/home/report/ReportTypingIndicator.js index 785f1e3f6a1e..41471eaa50de 100755 --- a/src/pages/home/report/ReportTypingIndicator.js +++ b/src/pages/home/report/ReportTypingIndicator.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; +import React, {memo, useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Text from '@components/Text'; @@ -68,4 +68,4 @@ export default withOnyx({ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, initialValue: {}, }, -})(ReportTypingIndicator); +})(memo(ReportTypingIndicator)); diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js index 94c2f1c31242..be3afb822723 100644 --- a/src/pages/iou/SplitBillDetailsPage.js +++ b/src/pages/iou/SplitBillDetailsPage.js @@ -102,6 +102,7 @@ function SplitBillDetailsPage(props) { created: splitCreated, category: splitCategory, tag: splitTag, + billable: splitBillable, } = isEditingSplitBill && props.draftTransaction ? ReportUtils.getTransactionDetails(props.draftTransaction) : ReportUtils.getTransactionDetails(props.transaction); const onConfirm = useCallback( @@ -133,6 +134,7 @@ function SplitBillDetailsPage(props) { iouMerchant={splitMerchant} iouCategory={splitCategory} iouTag={splitTag} + iouIsBillable={splitBillable} iouType={CONST.IOU.TYPE.SPLIT} isReadOnly={!isEditingSplitBill} shouldShowSmartScanFields @@ -146,7 +148,7 @@ function SplitBillDetailsPage(props) { transaction={isEditingSplitBill ? props.draftTransaction || props.transaction : props.transaction} onConfirm={onConfirm} isPolicyExpenseChat={ReportUtils.isPolicyExpenseChat(props.report)} - policyID={ReportUtils.isPolicyExpenseChat(props.report) && props.report.policyID} + policyID={ReportUtils.isPolicyExpenseChat(props.report) ? props.report.policyID : null} /> )} diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index 6028a735d132..0f1c2b27ad2e 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -226,6 +226,7 @@ function IOURequestStepConfirmation({ transaction.tag, receiptFile, report.reportID, + transaction.billable, ); return; } @@ -244,6 +245,7 @@ function IOURequestStepConfirmation({ transaction.category, transaction.tag, report.reportID, + transaction.billable, ); return; } @@ -260,6 +262,7 @@ function IOURequestStepConfirmation({ transaction.merchant, transaction.category, transaction.tag, + transaction.billable, ); return; } diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 89d863c47baa..4d9bd168f2f8 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -1,4 +1,3 @@ -import type {RouteProp} from '@react-navigation/native'; import React, {useEffect, useMemo, useRef} from 'react'; import type {ReactNode} from 'react'; import {View} from 'react-native'; @@ -21,6 +20,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {Policy, ReimbursementAccount, User} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {PolicyRoute} from './withPolicy'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; @@ -41,10 +41,10 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & headerText: string; /** The route object passed to this page from the navigator */ - route: RouteProp<{params: {policyID: string}}>; + route: PolicyRoute; /** Main content of the page */ - children: (hasVBA?: boolean, policyID?: string, isUsingECard?: boolean) => ReactNode; + children: (hasVBA: boolean, policyID: string, isUsingECard: boolean) => ReactNode; /** Content to be added as fixed footer */ footer?: ReactNode; @@ -93,7 +93,7 @@ function WorkspacePageWithSections({ const isLoading = reimbursementAccount?.isLoading ?? true; const achState = reimbursementAccount?.achData?.state ?? ''; const isUsingECard = user?.isUsingExpensifyCard ?? false; - const policyID = route.params.policyID; + const policyID = route.params?.policyID ?? ''; const policyName = policy?.name; const hasVBA = achState === BankAccount.STATE.OPEN; const content = children(hasVBA, policyID, isUsingECard); diff --git a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js b/src/pages/workspace/bills/WorkspaceBillsFirstSection.tsx similarity index 54% rename from src/pages/workspace/bills/WorkspaceBillsFirstSection.js rename to src/pages/workspace/bills/WorkspaceBillsFirstSection.tsx index 3f7f59180c8b..5931e8b14843 100644 --- a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js +++ b/src/pages/workspace/bills/WorkspaceBillsFirstSection.tsx @@ -1,56 +1,47 @@ import Str from 'expensify-common/lib/str'; -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import CopyTextToClipboard from '@components/CopyTextToClipboard'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import Section from '@components/Section'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import userPropTypes from '@pages/settings/userPropTypes'; import * as Link from '@userActions/Link'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Session, User} from '@src/types/onyx'; -const propTypes = { - /** The policy ID currently being configured */ - policyID: PropTypes.string.isRequired, - - ...withLocalizePropTypes, - - /* From Onyx */ +type WorkspaceBillsFirstSectionOnyxProps = { /** Session of currently logged in user */ - session: PropTypes.shape({ - /** Email address */ - email: PropTypes.string.isRequired, - }), + session: OnyxEntry; /** Information about the logged in user's account */ - user: userPropTypes, + user: OnyxEntry; }; -const defaultProps = { - session: { - email: null, - }, - user: {}, +type WorkspaceBillsFirstSectionProps = WorkspaceBillsFirstSectionOnyxProps & { + /** The policy ID currently being configured */ + policyID: string; }; -function WorkspaceBillsFirstSection(props) { +function WorkspaceBillsFirstSection({session, policyID, user}: WorkspaceBillsFirstSectionProps) { const styles = useThemeStyles(); - const emailDomain = Str.extractEmailDomain(props.session.email); - const manageYourBillsUrl = `reports?policyID=${props.policyID}&from=all&type=bill&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`; + const {translate} = useLocalize(); + + const emailDomain = Str.extractEmailDomain(session?.email ?? ''); + const manageYourBillsUrl = `reports?policyID=${policyID}&from=all&type=bill&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`; + return (

Link.openOldDotLink(manageYourBillsUrl), icon: Expensicons.Bill, shouldShowRightIcon: true, @@ -59,40 +50,35 @@ function WorkspaceBillsFirstSection(props) { link: () => Link.buildOldDotURL(manageYourBillsUrl), }, ]} - containerStyles={[styles.cardSection]} + containerStyles={styles.cardSection} > - + - {props.translate('workspace.bills.askYourVendorsBeforeEmail')} - {props.user.isFromPublicDomain ? ( + {translate('workspace.bills.askYourVendorsBeforeEmail')} + {user?.isFromPublicDomain ? ( Link.openExternalLink('https://community.expensify.com/discussion/7500/how-to-pay-your-company-bills-in-expensify/')}> example.com@expensify.cash ) : ( )} - {props.translate('workspace.bills.askYourVendorsAfterEmail')} + {translate('workspace.bills.askYourVendorsAfterEmail')}
); } -WorkspaceBillsFirstSection.propTypes = propTypes; -WorkspaceBillsFirstSection.defaultProps = defaultProps; WorkspaceBillsFirstSection.displayName = 'WorkspaceBillsFirstSection'; -export default compose( - withLocalize, - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - user: { - key: ONYXKEYS.USER, - }, - }), -)(WorkspaceBillsFirstSection); +export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + user: { + key: ONYXKEYS.USER, + }, +})(WorkspaceBillsFirstSection); diff --git a/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.tsx similarity index 50% rename from src/pages/workspace/bills/WorkspaceBillsNoVBAView.js rename to src/pages/workspace/bills/WorkspaceBillsNoVBAView.tsx index 8211f70163d5..72f4d0ad2b8e 100644 --- a/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js +++ b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.tsx @@ -1,45 +1,43 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import ConnectBankAccountButton from '@components/ConnectBankAccountButton'; import * as Illustrations from '@components/Icon/Illustrations'; import Section from '@components/Section'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import WorkspaceBillsFirstSection from './WorkspaceBillsFirstSection'; -const propTypes = { +type WorkspaceBillsNoVBAViewProps = { /** The policy ID currently being configured */ - policyID: PropTypes.string.isRequired, - - ...withLocalizePropTypes, + policyID: string; }; -function WorkspaceBillsNoVBAView(props) { +function WorkspaceBillsNoVBAView({policyID}: WorkspaceBillsNoVBAViewProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); + return ( <> - +
- - {props.translate('workspace.bills.unlockNoVBACopy')} + + {translate('workspace.bills.unlockNoVBACopy')}
); } -WorkspaceBillsNoVBAView.propTypes = propTypes; WorkspaceBillsNoVBAView.displayName = 'WorkspaceBillsNoVBAView'; -export default withLocalize(WorkspaceBillsNoVBAView); +export default WorkspaceBillsNoVBAView; diff --git a/src/pages/workspace/bills/WorkspaceBillsPage.js b/src/pages/workspace/bills/WorkspaceBillsPage.js deleted file mode 100644 index c607071a4365..000000000000 --- a/src/pages/workspace/bills/WorkspaceBillsPage.js +++ /dev/null @@ -1,42 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; -import CONST from '@src/CONST'; -import WorkspaceBillsNoVBAView from './WorkspaceBillsNoVBAView'; -import WorkspaceBillsVBAView from './WorkspaceBillsVBAView'; - -const propTypes = { - /** The route object passed to this page from the navigator */ - route: PropTypes.shape({ - /** Each parameter passed via the URL */ - params: PropTypes.shape({ - /** The policyID that is being configured */ - policyID: PropTypes.string.isRequired, - }).isRequired, - }).isRequired, - - ...withLocalizePropTypes, -}; - -function WorkspaceBillsPage(props) { - return ( - - {(hasVBA, policyID) => ( - <> - {!hasVBA && } - {hasVBA && } - - )} - - ); -} - -WorkspaceBillsPage.propTypes = propTypes; -WorkspaceBillsPage.displayName = 'WorkspaceBillsPage'; -export default withLocalize(WorkspaceBillsPage); diff --git a/src/pages/workspace/bills/WorkspaceBillsPage.tsx b/src/pages/workspace/bills/WorkspaceBillsPage.tsx new file mode 100644 index 000000000000..da86839f39c1 --- /dev/null +++ b/src/pages/workspace/bills/WorkspaceBillsPage.tsx @@ -0,0 +1,35 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import useLocalize from '@hooks/useLocalize'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import WorkspaceBillsNoVBAView from './WorkspaceBillsNoVBAView'; +import WorkspaceBillsVBAView from './WorkspaceBillsVBAView'; + +type WorkspaceBillsPageProps = StackScreenProps; + +function WorkspaceBillsPage({route}: WorkspaceBillsPageProps) { + const {translate} = useLocalize(); + + return ( + + {(hasVBA: boolean, policyID: string) => ( + <> + {!hasVBA && } + {hasVBA && } + + )} + + ); +} + +WorkspaceBillsPage.displayName = 'WorkspaceBillsPage'; + +export default WorkspaceBillsPage; diff --git a/src/pages/workspace/bills/WorkspaceBillsVBAView.js b/src/pages/workspace/bills/WorkspaceBillsVBAView.tsx similarity index 59% rename from src/pages/workspace/bills/WorkspaceBillsVBAView.js rename to src/pages/workspace/bills/WorkspaceBillsVBAView.tsx index dd9c1c7fbaf6..ae06fac378dd 100644 --- a/src/pages/workspace/bills/WorkspaceBillsVBAView.js +++ b/src/pages/workspace/bills/WorkspaceBillsVBAView.tsx @@ -1,36 +1,35 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import Section from '@components/Section'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Link from '@userActions/Link'; import WorkspaceBillsFirstSection from './WorkspaceBillsFirstSection'; -const propTypes = { +type WorkspaceBillsVBAViewProps = { /** The policy ID currently being configured */ - policyID: PropTypes.string.isRequired, - - ...withLocalizePropTypes, + policyID: string; }; -function WorkspaceBillsVBAView(props) { +function WorkspaceBillsVBAView({policyID}: WorkspaceBillsVBAViewProps) { const styles = useThemeStyles(); - const reportsUrl = `reports?policyID=${props.policyID}&from=all&type=bill&showStates=Processing,Approved&isAdvancedFilterMode=true`; + const {translate} = useLocalize(); + + const reportsUrl = `reports?policyID=${policyID}&from=all&type=bill&showStates=Processing,Approved&isAdvancedFilterMode=true`; return ( <> - +
Link.openOldDotLink(reportsUrl), icon: Expensicons.Bill, shouldShowRightIcon: true, @@ -40,15 +39,14 @@ function WorkspaceBillsVBAView(props) { }, ]} > - - {props.translate('workspace.bills.VBACopy')} + + {translate('workspace.bills.VBACopy')}
); } -WorkspaceBillsVBAView.propTypes = propTypes; WorkspaceBillsVBAView.displayName = 'WorkspaceBillsVBAView'; -export default withLocalize(WorkspaceBillsVBAView); +export default WorkspaceBillsVBAView; diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx index 79ff76204c69..ffd9a700ae7e 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx @@ -1,15 +1,14 @@ -import type {RouteProp} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import useLocalize from '@hooks/useLocalize'; +import type {SettingsNavigatorParamList} from '@navigation/types'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; import WorkspaceInvoicesNoVBAView from './WorkspaceInvoicesNoVBAView'; import WorkspaceInvoicesVBAView from './WorkspaceInvoicesVBAView'; -/** Defined route object that contains the policyID param, WorkspacePageWithSections is a common component for Workspaces and expect the route prop that includes the policyID */ -type WorkspaceInvoicesPageProps = { - route: RouteProp<{params: {policyID: string}}>; -}; +type WorkspaceInvoicesPageProps = StackScreenProps; function WorkspaceInvoicesPage({route}: WorkspaceInvoicesPageProps) { const {translate} = useLocalize(); diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index ec38b61fb0dc..aee03f1f74e9 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -5,13 +5,16 @@ import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; import React, {forwardRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {SettingsNavigatorParamList} from '@navigation/types'; import policyMemberPropType from '@pages/policyMemberPropType'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -type PolicyRoute = RouteProp<{params: {policyID: string}}>; +type PolicyRoute = RouteProp>; function getPolicyIDFromRoute(route: PolicyRoute): string { return route?.params?.policyID ?? ''; @@ -131,4 +134,4 @@ export default function (WrappedComponent: } export {policyPropTypes, policyDefaultProps}; -export type {WithPolicyOnyxProps, WithPolicyProps}; +export type {WithPolicyOnyxProps, WithPolicyProps, PolicyRoute}; diff --git a/src/types/onyx/Form.ts b/src/types/onyx/Form.ts index c015627bfbc2..9c6d52a1020d 100644 --- a/src/types/onyx/Form.ts +++ b/src/types/onyx/Form.ts @@ -57,6 +57,8 @@ type PrivateNotesForm = Form<{ type PersonalBankAccountForm = Form; +type ReportFieldEditForm = Form>; + export default Form; export type { @@ -70,4 +72,5 @@ export type { IKnowATeacherForm, IntroSchoolPrincipalForm, PersonalBankAccountForm, + ReportFieldEditForm, }; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 070b91e2d920..98732df57e38 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -284,6 +284,7 @@ export type { OriginalMessageIOU, OriginalMessageCreated, OriginalMessageAddComment, + OriginalMessageChronosOOOList, OriginalMessageSource, OriginalMessageReimbursementDequeued, PaymentMethodType, diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 2a3477ba7c2c..fe50bbb497d2 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -88,10 +88,15 @@ type Policy = { /** The scheduled submit frequency set up on this policy */ autoReportingFrequency?: ValueOf; - /** Whether the scheduled submit is enabled */ + /** @deprecated Whether the scheduled submit is enabled */ isHarvestingEnabled?: boolean; /** Whether the scheduled submit is enabled */ + harvesting?: { + enabled: boolean; + }; + + /** Whether the self approval or submitting is enabled */ isPreventSelfApprovalEnabled?: boolean; /** When the monthly scheduled submit should happen */ diff --git a/src/types/onyx/PolicyReportField.ts b/src/types/onyx/PolicyReportField.ts index a1724a9ff52f..de385070aa25 100644 --- a/src/types/onyx/PolicyReportField.ts +++ b/src/types/onyx/PolicyReportField.ts @@ -19,6 +19,9 @@ type PolicyReportField = { /** Tells if the field is required or not */ deletable: boolean; + /** Value of the field */ + value: string; + /** Options to select from if field is of type dropdown */ values: string[]; }; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index f3b20c68038e..d26bdd4f282e 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -2,6 +2,7 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; import type * as OnyxCommon from './OnyxCommon'; import type PersonalDetails from './PersonalDetails'; +import type {PolicyReportField} from './PolicyReportField'; type NotificationPreference = ValueOf; @@ -170,7 +171,7 @@ type Report = { selected?: boolean; /** If the report contains reportFields, save the field id and its value */ - reportFields?: Record; + reportFields?: Record; }; export default Report; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 5b04cae58671..64eec736b5bf 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -9,7 +9,7 @@ import type Credentials from './Credentials'; import type Currency from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; import type Download from './Download'; -import type {AddDebitCardForm, DateOfBirthForm, DisplayNameForm, IKnowATeacherForm, IntroSchoolPrincipalForm, NewRoomForm, PrivateNotesForm} from './Form'; +import type {AddDebitCardForm, DateOfBirthForm, DisplayNameForm, IKnowATeacherForm, IntroSchoolPrincipalForm, NewRoomForm, PrivateNotesForm, ReportFieldEditForm} from './Form'; import type Form from './Form'; import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import type {FundList} from './Fund'; @@ -151,4 +151,5 @@ export type { IKnowATeacherForm, IntroSchoolPrincipalForm, PrivateNotesForm, + ReportFieldEditForm, }; diff --git a/tests/perf-test/ReportScreen.perf-test.js b/tests/perf-test/ReportScreen.perf-test.js index 5a144e715f5b..dbc7775d066e 100644 --- a/tests/perf-test/ReportScreen.perf-test.js +++ b/tests/perf-test/ReportScreen.perf-test.js @@ -15,7 +15,10 @@ import * as Localize from '../../src/libs/Localize'; import ONYXKEYS from '../../src/ONYXKEYS'; import {ReportAttachmentsProvider} from '../../src/pages/home/report/ReportAttachmentsContext'; import ReportScreen from '../../src/pages/home/ReportScreen'; -import * as LHNTestUtils from '../utils/LHNTestUtils'; +import createCollection from '../utils/collections/createCollection'; +import createPersonalDetails from '../utils/collections/personalDetails'; +import createRandomPolicy from '../utils/collections/policies'; +import createRandomReport from '../utils/collections/reports'; import PusherHelper from '../utils/PusherHelper'; import * as ReportTestUtils from '../utils/ReportTestUtils'; import * as TestHelper from '../utils/TestHelper'; @@ -56,6 +59,7 @@ jest.mock('../../src/hooks/useEnvironment', () => jest.mock('../../src/libs/Permissions', () => ({ canUseLinkPreviews: jest.fn(() => true), + canUseDefaultRooms: jest.fn(() => true), })); jest.mock('../../src/hooks/usePermissions.ts'); @@ -103,6 +107,18 @@ afterEach(() => { PusherHelper.teardown(); }); +const policies = createCollection( + (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, + (index) => createRandomPolicy(index), + 10, +); + +const personalDetails = createCollection( + (item) => item.accountID, + (index) => createPersonalDetails(index), + 20, +); + /** * This is a helper function to create a mock for the addListener function of the react-navigation library. * The reason we need this is because we need to trigger the transitionEnd event in our tests to simulate @@ -152,7 +168,11 @@ function ReportScreenWrapper(args) { ); } -test.skip('[ReportScreen] should render ReportScreen with composer interactions', () => { +const report = {...createRandomReport(1), policyID: '1'}; +const reportActions = ReportTestUtils.getMockedReportActionsMap(500); +const mockRoute = {params: {reportID: '1'}}; + +test('[ReportScreen] should render ReportScreen with composer interactions', () => { const {triggerTransitionEnd, addListener} = createAddListenerMock(); const scenario = async () => { /** @@ -166,9 +186,6 @@ test.skip('[ReportScreen] should render ReportScreen with composer interactions' await act(triggerTransitionEnd); - // Query for the report list - await screen.findByTestId('report-actions-list'); - // Query for the composer const composer = await screen.findByTestId('composer'); @@ -189,15 +206,6 @@ test.skip('[ReportScreen] should render ReportScreen with composer interactions' await screen.findByLabelText(hintHeaderText); }; - const policy = { - policyID: 1, - name: 'Testing Policy', - }; - - const report = LHNTestUtils.getFakeReport(); - const reportActions = ReportTestUtils.getMockedReportActionsMap(1000); - const mockRoute = {params: {reportID: '1'}}; - const navigation = {addListener}; return waitForBatchedUpdates() @@ -206,9 +214,9 @@ test.skip('[ReportScreen] should render ReportScreen with composer interactions' [ONYXKEYS.IS_SIDEBAR_LOADED]: true, [`${ONYXKEYS.COLLECTION.REPORT}${mockRoute.params.reportID}`]: report, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockRoute.params.reportID}`]: reportActions, - [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails, [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], - [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy, + [`${ONYXKEYS.COLLECTION.POLICY}`]: policies, [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${mockRoute.params.reportID}`]: { isLoadingReportActions: false, }, @@ -225,7 +233,7 @@ test.skip('[ReportScreen] should render ReportScreen with composer interactions' ); }); -test.skip('[ReportScreen] should press of the report item', () => { +test('[ReportScreen] should press of the report item', () => { const {triggerTransitionEnd, addListener} = createAddListenerMock(); const scenario = async () => { /** @@ -242,9 +250,6 @@ test.skip('[ReportScreen] should press of the report item', () => { // Query for the report list await screen.findByTestId('report-actions-list'); - // Query for the composer - await screen.findByTestId('composer'); - const hintReportPreviewText = Localize.translateLocal('iou.viewDetails'); // Query for report preview buttons @@ -254,15 +259,6 @@ test.skip('[ReportScreen] should press of the report item', () => { fireEvent.press(reportPreviewButtons[0]); }; - const policy = { - policyID: 123, - name: 'Testing Policy', - }; - - const report = LHNTestUtils.getFakeReport(); - const reportActions = ReportTestUtils.getMockedReportActionsMap(1000); - const mockRoute = {params: {reportID: '2'}}; - const navigation = {addListener}; return waitForBatchedUpdates() @@ -271,9 +267,9 @@ test.skip('[ReportScreen] should press of the report item', () => { [ONYXKEYS.IS_SIDEBAR_LOADED]: true, [`${ONYXKEYS.COLLECTION.REPORT}${mockRoute.params.reportID}`]: report, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockRoute.params.reportID}`]: reportActions, - [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails, [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS], - [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy, + [`${ONYXKEYS.COLLECTION.POLICY}`]: policies, [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${mockRoute.params.reportID}`]: { isLoadingReportActions: false, }, diff --git a/tests/unit/ReportActionItemSingleTest.js b/tests/unit/ReportActionItemSingleTest.js index 55cae01c19f1..08fac8e77551 100644 --- a/tests/unit/ReportActionItemSingleTest.js +++ b/tests/unit/ReportActionItemSingleTest.js @@ -1,4 +1,4 @@ -import {cleanup, screen} from '@testing-library/react-native'; +import {cleanup, screen, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -70,7 +70,9 @@ describe('ReportActionItemSingle', () => { const expectedSecondaryIconTestId = 'SvgDefaultAvatar_w Icon'; return setup().then(() => { - expect(screen.getByTestId(expectedSecondaryIconTestId)).toBeDefined(); + waitFor(() => { + expect(screen.getByTestId(expectedSecondaryIconTestId)).toBeDefined(); + }); }); }); diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js index 6c72558e5df3..04246c1c438a 100644 --- a/tests/utils/LHNTestUtils.js +++ b/tests/utils/LHNTestUtils.js @@ -256,7 +256,9 @@ function getFakePolicy(id = 1, name = 'Workspace-Test-001') { lastModified: 1697323926777105, autoReporting: true, autoReportingFrequency: 'immediate', - isHarvestingEnabled: true, + harvesting: { + enabled: true, + }, autoReportingOffset: 1, isPreventSelfApprovalEnabled: true, submitsTo: 123456, diff --git a/tests/utils/ReportTestUtils.js b/tests/utils/ReportTestUtils.js index 910f2200876b..86899e4045f6 100644 --- a/tests/utils/ReportTestUtils.js +++ b/tests/utils/ReportTestUtils.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import createRandomReportAction from './collections/reportActions'; const actionNames = ['ADDCOMMENT', 'IOU', 'REPORTPREVIEW', 'CLOSED']; @@ -51,7 +52,13 @@ const getMockedReportActionsMap = (length = 100) => { const mockReports = Array.from({length}, (__, i) => { const reportID = i + 1; const actionName = i === 0 ? 'CREATED' : actionNames[i % actionNames.length]; - const reportAction = getFakeReportAction(reportID, actionName); + const reportAction = { + ...createRandomReportAction(reportID), + actionName, + originalMessage: { + linkedReportID: reportID.toString(), + }, + }; return {[reportID]: reportAction}; }); diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 8547c171c7a7..4223c7e41941 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -11,7 +11,9 @@ export default function createRandomPolicy(index: number): Policy { autoReporting: randBoolean(), isPolicyExpenseChatEnabled: randBoolean(), autoReportingFrequency: rand(Object.values(CONST.POLICY.AUTO_REPORTING_FREQUENCIES)), - isHarvestingEnabled: randBoolean(), + harvesting: { + enabled: randBoolean(), + }, autoReportingOffset: 1, isPreventSelfApprovalEnabled: randBoolean(), submitsTo: index, diff --git a/tests/utils/collections/reportActions.ts b/tests/utils/collections/reportActions.ts index cc258e89c041..bb14a2c7a41b 100644 --- a/tests/utils/collections/reportActions.ts +++ b/tests/utils/collections/reportActions.ts @@ -1,4 +1,5 @@ -import {rand, randAggregation, randBoolean, randPastDate, randWord} from '@ngneat/falso'; +import {rand, randAggregation, randBoolean, randWord} from '@ngneat/falso'; +import {format} from 'date-fns'; import CONST from '@src/CONST'; import type {ReportAction} from '@src/types/onyx'; @@ -17,6 +18,15 @@ const flattenActionNamesValues = (actionNames: any) => { return result; }; +const getRandomDate = (): string => { + const randomTimestamp = Math.random() * new Date().getTime(); + const randomDate = new Date(randomTimestamp); + + const formattedDate = format(randomDate, CONST.DATE.FNS_DB_FORMAT_STRING); + + return formattedDate; +}; + export default function createRandomReportAction(index: number): ReportAction { return { // we need to add any here because of the way we are generating random values @@ -32,7 +42,7 @@ export default function createRandomReportAction(index: number): ReportAction { text: randWord(), }, ], - created: randPastDate().toISOString(), + created: getRandomDate(), message: [ { type: randWord(), @@ -57,13 +67,13 @@ export default function createRandomReportAction(index: number): ReportAction { ], originalMessage: { html: randWord(), - type: rand(Object.values(CONST.IOU.REPORT_ACTION_TYPE)), + lastModified: getRandomDate(), }, whisperedToAccountIDs: randAggregation(), avatar: randWord(), automatic: randBoolean(), shouldShow: randBoolean(), - lastModified: randPastDate().toISOString(), + lastModified: getRandomDate(), pendingAction: rand(Object.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)), delegateAccountID: index, errors: {}, diff --git a/web/index.html b/web/index.html index aff3feb87dbb..49ffd0d0a62f 100644 --- a/web/index.html +++ b/web/index.html @@ -111,10 +111,6 @@ transition-property: opacity; } - .splash-logo > svg { - color: #03d47c; - } - .animation { display: flex; }