diff --git a/.eslintrc.changed.js b/.eslintrc.changed.js index a1c2c452273e..55472b10ea86 100644 --- a/.eslintrc.changed.js +++ b/.eslintrc.changed.js @@ -11,15 +11,14 @@ module.exports = { overrides: [ { files: [ - 'src/libs/ReportUtils.ts', 'src/libs/actions/IOU.ts', 'src/libs/actions/Report.ts', 'src/libs/actions/Task.ts', 'src/libs/OptionsListUtils.ts', - 'src/libs/ReportActionsUtils.ts', 'src/libs/TransactionUtils/index.ts', 'src/pages/home/ReportScreen.tsx', 'src/pages/workspace/WorkspaceInitialPage.tsx', + 'src/pages/home/report/PureReportActionItem.tsx', ], rules: { 'rulesdir/no-default-id-values': 'off', diff --git a/Mobile-Expensify b/Mobile-Expensify index e2fc81303262..70feac1132e2 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit e2fc813032623ef5e44461d94875885e1b88c2e1 +Subproject commit 70feac1132e2413cc95c914f1dfd9bf45ee3300d diff --git a/android/app/build.gradle b/android/app/build.gradle index c40a999ba146..934ead071108 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009008002 - versionName "9.0.80-2" + versionCode 1009008006 + versionName "9.0.80-6" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 154015e809e9..373f82eedbc5 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.80.2 + 9.0.80.6 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 1eb95e1ae604..7234f53399aa 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.80.2 + 9.0.80.6 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 98cf6fa0ffb7..8329f8362678 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.80 CFBundleVersion - 9.0.80.2 + 9.0.80.6 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 024b6bb90596..5b11b5190858 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.80-2", + "version": "9.0.80-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.80-2", + "version": "9.0.80-6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a3b01212c816..ad40eff9a75b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.80-2", + "version": "9.0.80-6", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/patches/react-native-reanimated+3.16.4+001+mock-useDerivedValue-getter.patch b/patches/react-native-reanimated+3.16.4+001+mock-useDerivedValue-getter.patch deleted file mode 100644 index 972ddeedf67a..000000000000 --- a/patches/react-native-reanimated+3.16.4+001+mock-useDerivedValue-getter.patch +++ /dev/null @@ -1,18 +0,0 @@ -diff --git a/node_modules/react-native-reanimated/src/mock.ts b/node_modules/react-native-reanimated/src/mock.ts -index 3d8e3f8..5eba613 100644 ---- a/node_modules/react-native-reanimated/src/mock.ts -+++ b/node_modules/react-native-reanimated/src/mock.ts -@@ -87,7 +87,12 @@ const hook = { - useAnimatedReaction: NOOP, - useAnimatedRef: () => ({ current: null }), - useAnimatedScrollHandler: NOOP_FACTORY, -- useDerivedValue: (processor: () => Value) => ({ value: processor() }), -+ // https://github.com/software-mansion/react-native-reanimated/pull/6809 -+ useDerivedValue: (processor: () => Value) => { -+ const result = processor(); -+ -+ return { value: result, get: () => result }; -+ }, - useAnimatedSensor: () => ({ - sensor: { - value: { diff --git a/src/App.tsx b/src/App.tsx index 5de99365aadb..cc824b78fa4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,6 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; -import * as ActionSheetAwareScrollView from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; import ActiveWorkspaceContextProvider from './components/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; @@ -90,7 +89,6 @@ function App({url}: AppProps) { CustomStatusBarAndBackgroundContextProvider, ActiveElementRoleProvider, ActiveWorkspaceContextProvider, - ActionSheetAwareScrollView.ActionSheetAwareScrollViewProvider, ReportIDsContextProvider, PlaybackContextProvider, FullScreenContextProvider, diff --git a/src/CONST.ts b/src/CONST.ts index 17f25edb6805..2d70c9355651 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -687,7 +687,7 @@ const CONST = { COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit', CATEGORY_AND_TAG_APPROVERS: 'categoryAndTagApprovers', PER_DIEM: 'newDotPerDiem', - PRODUCT_TRAINING: 'productTraining', + NEWDOT_MERGE_ACCOUNTS: 'newDotMergeAccounts', }, BUTTON_STATES: { DEFAULT: 'default', @@ -939,7 +939,6 @@ const CONST = { CONFIGURE_REIMBURSEMENT_SETTINGS_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/workspaces/Configure-Reimbursement-Settings', COPILOT_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot', DELAYED_SUBMISSION_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/reports/Automatically-submit-employee-reports', - ENCRYPTION_AND_SECURITY_HELP_URL: 'https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security', PLAN_TYPES_AND_PRICING_HELP_URL: 'https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing', // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', @@ -2360,6 +2359,7 @@ const CONST = { DISTANCE: 'distance', MANUAL: 'manual', SCAN: 'scan', + PER_DIEM: 'per-diem', }, REPORT_ACTION_TYPE: { PAY: 'pay', @@ -2980,6 +2980,50 @@ const CONST = { PAYPERUSE: 'monthly2018', }, }, + get SUBSCRIPTION_PRICES() { + return { + [this.PAYMENT_CARD_CURRENCY.USD]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 900, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1800, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 500, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1000, + }, + }, + [this.PAYMENT_CARD_CURRENCY.AUD]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 1500, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 3000, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + }, + [this.PAYMENT_CARD_CURRENCY.GBP]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 400, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 800, + }, + }, + [this.PAYMENT_CARD_CURRENCY.NZD]: { + [this.POLICY.TYPE.CORPORATE]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 1600, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 3200, + }, + [this.POLICY.TYPE.TEAM]: { + [this.SUBSCRIPTION.TYPE.ANNUAL]: 800, + [this.SUBSCRIPTION.TYPE.PAYPERUSE]: 1600, + }, + }, + }; + }, REGEX: { SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g, DIGITS_AND_PLUS: /^\+?[0-9]*$/, @@ -3167,6 +3211,7 @@ const CONST = { REPORT_NAME_LIMIT: 100, TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 1000, + SEARCH_QUERY_LIMIT: 1000, WORKSPACE_NAME_CHARACTER_LIMIT: 80, STATE_CHARACTER_LIMIT: 32, @@ -3204,6 +3249,7 @@ const CONST = { CANCEL_PAYMENT: 'cancelPayment', UNAPPROVE: 'unapprove', DEBUG: 'debug', + GO_TO_WORKSPACE: 'goToWorkspace', }, EDIT_REQUEST_FIELD: { AMOUNT: 'amount', @@ -4613,6 +4659,7 @@ const CONST = { MANUAL: 'manual', SCAN: 'scan', DISTANCE: 'distance', + PER_DIEM: 'per-diem', }, STATUS_TEXT_MAX_LENGTH: 100, @@ -6441,50 +6488,6 @@ const CONST = { }, }, - CORPAY_FIELDS: { - BANK_ACCOUNT_DETAILS_FIELDS: ['accountNumber', 'localAccountNumber', 'routingCode', 'localRoutingCode', 'swiftBicCode'] as string[], - ACCOUNT_TYPE_KEY: 'BeneficiaryAccountType', - BANK_INFORMATION_FIELDS: ['bankName', 'bankAddressLine1', 'bankAddressLine2', 'bankCity', 'bankRegion', 'bankPostal', 'BeneficiaryBankBranchName'] as string[], - ACCOUNT_HOLDER_FIELDS: [ - 'accountHolderName', - 'accountHolderAddress1', - 'accountHolderAddress2', - 'accountHolderCity', - 'accountHolderRegion', - 'accountHolderCountry', - 'accountHolderPostal', - 'accountHolderPhoneNumber', - 'accountHolderEmail', - 'ContactName', - 'BeneficiaryCPF', - 'BeneficiaryRUT', - 'BeneficiaryCedulaID', - 'BeneficiaryTaxID', - ] as string[], - SPECIAL_LIST_REGION_KEYS: ['bankRegion', 'accountHolderRegion'] as string[], - SPECIAL_LIST_ADDRESS_KEYS: ['bankAddressLine1', 'accountHolderAddress1'] as string[], - STEPS_NAME: { - COUNTRY_SELECTOR: 'CountrySelector', - BANK_ACCOUNT_DETAILS: 'BankAccountDetails', - ACCOUNT_TYPE: 'AccountType', - BANK_INFORMATION: 'BankInformation', - ACCOUNT_HOLDER_INFORMATION: 'AccountHolderInformation', - CONFIRMATION: 'Confirmation', - SUCCESS: 'Success', - }, - INDEXES: { - MAPPING: { - COUNTRY_SELECTOR: 0, - BANK_ACCOUNT_DETAILS: 1, - ACCOUNT_TYPE: 2, - BANK_INFORMATION: 3, - ACCOUNT_HOLDER_INFORMATION: 4, - CONFIRMATION: 5, - SUCCESS: 6, - }, - }, - }, - HYBRID_APP: { REORDERING_REACT_NATIVE_ACTIVITY_TO_FRONT: 'reorderingReactNativeActivityToFront', }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index cfb0afd8b33f..020eb5262200 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -475,6 +475,7 @@ const ONYXKEYS = { POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', + POLICY_RECENTLY_USED_DESTINATIONS: 'nvp_recentlyUsedDestinations_', // Whether the policy's connection data was attempted to be fetched in // the current user session. As this state only exists client-side, it // should not be included as part of the policy object. The policy @@ -600,8 +601,6 @@ const ONYXKEYS = { HOME_ADDRESS_FORM_DRAFT: 'homeAddressFormDraft', PERSONAL_DETAILS_FORM: 'personalDetailsForm', PERSONAL_DETAILS_FORM_DRAFT: 'personalDetailsFormDraft', - INTERNATIONAL_BANK_ACCOUNT_FORM: 'internationalBankAccountForm', - INTERNATIONAL_BANK_ACCOUNT_FORM_DRAFT: 'internationalBankAccountFormDraft', NEW_ROOM_FORM: 'newRoomForm', NEW_ROOM_FORM_DRAFT: 'newRoomFormDraft', ROOM_SETTINGS_FORM: 'roomSettingsForm', @@ -622,6 +621,10 @@ const ONYXKEYS = { MONEY_REQUEST_HOLD_FORM_DRAFT: 'moneyHoldReasonFormDraft', MONEY_REQUEST_COMPANY_INFO_FORM: 'moneyRequestCompanyInfoForm', MONEY_REQUEST_COMPANY_INFO_FORM_DRAFT: 'moneyRequestCompanyInfoFormDraft', + MONEY_REQUEST_TIME_FORM: 'moneyRequestTimeForm', + MONEY_REQUEST_TIME_FORM_DRAFT: 'moneyRequestTimeFormDraft', + MONEY_REQUEST_SUBRATE_FORM: 'moneyRequestSubrateForm', + MONEY_REQUEST_SUBRATE_FORM_DRAFT: 'moneyRequestSubrateFormDraft', NEW_CONTACT_METHOD_FORM: 'newContactMethodForm', NEW_CONTACT_METHOD_FORM_DRAFT: 'newContactMethodFormDraft', WAYPOINT_FORM: 'waypointForm', @@ -767,6 +770,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.MoneyRequestMerchantForm; [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.MoneyRequestAmountForm; [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.MoneyRequestDateForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_TIME_FORM]: FormTypes.MoneyRequestTimeForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_SUBRATE_FORM]: FormTypes.MoneyRequestSubrateForm; [ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm; [ONYXKEYS.FORMS.MONEY_REQUEST_COMPANY_INFO_FORM]: FormTypes.MoneyRequestCompanyInfoForm; [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.NewContactMethodForm; @@ -821,7 +826,6 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; [ONYXKEYS.FORMS.SEARCH_SAVED_SEARCH_RENAME_FORM]: FormTypes.SearchSavedSearchRenameForm; [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm | FormTypes.DebugTransactionForm | FormTypes.DebugTransactionViolationForm; - [ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM]: FormTypes.InternationalBankAccountForm; [ONYXKEYS.FORMS.WORKSPACE_PER_DIEM_FORM]: FormTypes.WorkspacePerDiemForm; }; @@ -837,6 +841,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTagLists; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; + [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED]: boolean; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyEmployeeList; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 57b4f65a5bc6..d7774828a1c7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -200,7 +200,6 @@ const ROUTES = { }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', - SETTINGS_ADD_US_BANK_ACCOUNT: 'settings/wallet/add-us-bank-account', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS: { route: 'settings/wallet/card/:domain/digital-details/update-address', @@ -486,6 +485,36 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => getUrlWithBackToParam(`${action as string}/${iouType as string}/upgrade/${transactionID}/${reportID}`, backTo), }, + MONEY_REQUEST_STEP_DESTINATION: { + route: ':action/:iouType/destination/:transactionID/:reportID', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/destination/${transactionID}/${reportID}`, backTo), + }, + MONEY_REQUEST_STEP_TIME: { + route: ':action/:iouType/time/:transactionID/:reportID', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/time/${transactionID}/${reportID}`, backTo), + }, + MONEY_REQUEST_STEP_SUBRATE: { + route: ':action/:iouType/subrate/:transactionID/:reportID/:pageIndex', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/subrate/${transactionID}/${reportID}/0`, backTo), + }, + MONEY_REQUEST_STEP_DESTINATION_EDIT: { + route: ':action/:iouType/destination/:transactionID/:reportID/edit', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/destination/${transactionID}/${reportID}/edit`, backTo), + }, + MONEY_REQUEST_STEP_TIME_EDIT: { + route: ':action/:iouType/time/:transactionID/:reportID/edit', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/time/${transactionID}/${reportID}/edit`, backTo), + }, + MONEY_REQUEST_STEP_SUBRATE_EDIT: { + route: ':action/:iouType/subrate/:transactionID/:reportID/edit/:pageIndex', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, pageIndex = 0, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/subrate/${transactionID}/${reportID}/edit/${pageIndex}`, backTo), + }, SETTINGS_TAGS_ROOT: { route: 'settings/:policyID/tags', getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/tags`, backTo), @@ -646,6 +675,10 @@ const ROUTES = { route: ':action/:iouType/start/:transactionID/:reportID/scan', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => `create/${iouType as string}/start/${transactionID}/${reportID}/scan` as const, }, + MONEY_REQUEST_CREATE_TAB_PER_DIEM: { + route: ':action/:iouType/start/:transactionID/:reportID/per-diem', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => `create/${iouType as string}/start/${transactionID}/${reportID}/per-diem` as const, + }, MONEY_REQUEST_STATE_SELECTOR: { route: 'submit/state', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index d0dc80d3e9d9..2359324c9b90 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -69,7 +69,6 @@ const SCREENS = { ADD_DEBIT_CARD: 'Settings_Add_Debit_Card', ADD_PAYMENT_CARD_CHANGE_CURRENCY: 'Settings_Add_Payment_Card_Change_Currency', ADD_BANK_ACCOUNT: 'Settings_Add_Bank_Account', - ADD_US_BANK_ACCOUNT: 'Settings_Add_US_Bank_Account', CLOSE: 'Settings_Close', TWO_FACTOR_AUTH: 'Settings_TwoFactorAuth', REPORT_CARD_LOST_OR_DAMAGED: 'Settings_ReportCardLostOrDamaged', @@ -231,6 +230,12 @@ const SCREENS = { RECEIPT: 'Money_Request_Receipt', STATE_SELECTOR: 'Money_Request_State_Selector', STEP_ATTENDEES: 'Money_Request_Attendee', + STEP_DESTINATION: 'Money_Request_Destination', + STEP_TIME: 'Money_Request_Time', + STEP_SUBRATE: 'Money_Request_SubRate', + STEP_DESTINATION_EDIT: 'Money_Request_Destination_Edit', + STEP_TIME_EDIT: 'Money_Request_Time_Edit', + STEP_SUBRATE_EDIT: 'Money_Request_SubRate_Edit', }, TRANSACTION_DUPLICATE: { diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx deleted file mode 100644 index 6fd9914c70e1..000000000000 --- a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import noop from 'lodash/noop'; -import PropTypes from 'prop-types'; -import type {PropsWithChildren} from 'react'; -import React, {createContext, useMemo} from 'react'; -import type {SharedValue} from 'react-native-reanimated'; -import type {ActionWithPayload, State} from '@hooks/useWorkletStateMachine'; -import useWorkletStateMachine from '@hooks/useWorkletStateMachine'; - -type MeasuredElements = { - fy?: number; - popoverHeight?: number; - height?: number; - composerHeight?: number; -}; - -type Context = { - currentActionSheetState: SharedValue>; - transitionActionSheetState: (action: ActionWithPayload) => void; - transitionActionSheetStateWorklet: (action: ActionWithPayload) => void; - resetStateMachine: () => void; -}; - -/** Holds all information that are needed to coordinate the state value for the action sheet state machine. */ -const currentActionSheetStateValue = { - previous: { - state: 'idle', - payload: null, - }, - current: { - state: 'idle', - payload: null, - }, -}; -const defaultValue: Context = { - currentActionSheetState: { - value: currentActionSheetStateValue, - addListener: noop, - removeListener: noop, - modify: noop, - get: () => currentActionSheetStateValue, - set: noop, - }, - transitionActionSheetState: noop, - transitionActionSheetStateWorklet: noop, - resetStateMachine: noop, -}; - -const ActionSheetAwareScrollViewContext = createContext(defaultValue); - -const Actions = { - OPEN_KEYBOARD: 'KEYBOARD_OPEN', - CLOSE_KEYBOARD: 'CLOSE_KEYBOARD', - OPEN_POPOVER: 'OPEN_POPOVER', - CLOSE_POPOVER: 'CLOSE_POPOVER', - MEASURE_POPOVER: 'MEASURE_POPOVER', - MEASURE_COMPOSER: 'MEASURE_COMPOSER', - POPOVER_ANY_ACTION: 'POPOVER_ANY_ACTION', - HIDE_WITHOUT_ANIMATION: 'HIDE_WITHOUT_ANIMATION', - END_TRANSITION: 'END_TRANSITION', -}; - -const States = { - IDLE: 'idle', - KEYBOARD_OPEN: 'keyboardOpen', - POPOVER_OPEN: 'popoverOpen', - POPOVER_CLOSED: 'popoverClosed', - KEYBOARD_POPOVER_CLOSED: 'keyboardPopoverClosed', - KEYBOARD_POPOVER_OPEN: 'keyboardPopoverOpen', - KEYBOARD_CLOSED_POPOVER: 'keyboardClosingPopover', - POPOVER_MEASURED: 'popoverMeasured', - MODAL_WITH_KEYBOARD_OPEN_DELETED: 'modalWithKeyboardOpenDeleted', -}; - -const STATE_MACHINE = { - [States.IDLE]: { - [Actions.OPEN_POPOVER]: States.POPOVER_OPEN, - [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, - [Actions.MEASURE_POPOVER]: States.IDLE, - [Actions.MEASURE_COMPOSER]: States.IDLE, - }, - [States.POPOVER_OPEN]: { - [Actions.CLOSE_POPOVER]: States.POPOVER_CLOSED, - [Actions.MEASURE_POPOVER]: States.POPOVER_OPEN, - [Actions.MEASURE_COMPOSER]: States.POPOVER_OPEN, - [Actions.POPOVER_ANY_ACTION]: States.POPOVER_CLOSED, - [Actions.HIDE_WITHOUT_ANIMATION]: States.IDLE, - }, - [States.POPOVER_CLOSED]: { - [Actions.END_TRANSITION]: States.IDLE, - }, - [States.KEYBOARD_OPEN]: { - [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, - [Actions.OPEN_POPOVER]: States.KEYBOARD_POPOVER_OPEN, - [Actions.CLOSE_KEYBOARD]: States.IDLE, - [Actions.MEASURE_COMPOSER]: States.KEYBOARD_OPEN, - }, - [States.KEYBOARD_POPOVER_OPEN]: { - [Actions.MEASURE_POPOVER]: States.KEYBOARD_POPOVER_OPEN, - [Actions.CLOSE_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, - [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, - }, - [States.KEYBOARD_POPOVER_CLOSED]: { - [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, - }, - [States.KEYBOARD_CLOSED_POPOVER]: { - [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, - [Actions.END_TRANSITION]: States.KEYBOARD_OPEN, - }, -}; - -function ActionSheetAwareScrollViewProvider(props: PropsWithChildren) { - const {currentState, transition, transitionWorklet, reset} = useWorkletStateMachine(STATE_MACHINE, { - previous: { - state: 'idle', - payload: null, - }, - current: { - state: 'idle', - payload: null, - }, - }); - - const value = useMemo( - () => ({ - currentActionSheetState: currentState, - transitionActionSheetState: transition, - transitionActionSheetStateWorklet: transitionWorklet, - resetStateMachine: reset, - }), - [currentState, reset, transition, transitionWorklet], - ); - - return {props.children}; -} - -ActionSheetAwareScrollViewProvider.propTypes = { - children: PropTypes.node.isRequired, -}; - -export {ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions, States}; diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx deleted file mode 100644 index e15ac941a09d..000000000000 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React, {useContext, useEffect} from 'react'; -import type {ViewProps} from 'react-native'; -import {useKeyboardHandler} from 'react-native-keyboard-controller'; -import Reanimated, {useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, withTiming} from 'react-native-reanimated'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import {Actions, ActionSheetAwareScrollViewContext, States} from './ActionSheetAwareScrollViewContext'; - -const KeyboardState = { - UNKNOWN: 0, - OPENING: 1, - OPEN: 2, - CLOSING: 3, - CLOSED: 4, -}; - -const SPRING_CONFIG = { - mass: 3, - stiffness: 1000, - damping: 500, -}; - -const useAnimatedKeyboard = () => { - const state = useSharedValue(KeyboardState.UNKNOWN); - const height = useSharedValue(0); - const lastHeight = useSharedValue(0); - const heightWhenOpened = useSharedValue(0); - - useKeyboardHandler( - { - onStart: (e) => { - 'worklet'; - - // Save the last keyboard height - if (e.height !== 0) { - heightWhenOpened.set(e.height); - height.set(0); - } - height.set(heightWhenOpened.get()); - lastHeight.set(e.height); - state.set(e.height > 0 ? KeyboardState.OPENING : KeyboardState.CLOSING); - }, - onMove: (e) => { - 'worklet'; - - height.set(e.height); - }, - onEnd: (e) => { - 'worklet'; - - state.set(e.height > 0 ? KeyboardState.OPEN : KeyboardState.CLOSED); - height.set(e.height); - }, - }, - [], - ); - - return {state, height, heightWhenOpened}; -}; - -const useSafeAreaPaddings = () => { - const StyleUtils = useStyleUtils(); - const insets = useSafeAreaInsets(); - const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined); - - return {top: paddingTop, bottom: paddingBottom}; -}; - -function ActionSheetKeyboardSpace(props: ViewProps) { - const styles = useThemeStyles(); - const safeArea = useSafeAreaPaddings(); - const keyboard = useAnimatedKeyboard(); - - // Similar to using `global` in worklet but it's just a local object - const syncLocalWorkletState = useSharedValue(KeyboardState.UNKNOWN); - const {windowHeight} = useWindowDimensions(); - const {currentActionSheetState, transitionActionSheetStateWorklet: transition, resetStateMachine} = useContext(ActionSheetAwareScrollViewContext); - - // Reset state machine when component unmounts - // eslint-disable-next-line arrow-body-style - useEffect(() => { - return () => resetStateMachine(); - }, [resetStateMachine]); - - useAnimatedReaction( - () => keyboard.state.get(), - (lastState) => { - if (lastState === syncLocalWorkletState.get()) { - return; - } - // eslint-disable-next-line react-compiler/react-compiler - syncLocalWorkletState.set(lastState); - - if (lastState === KeyboardState.OPEN) { - transition({type: Actions.OPEN_KEYBOARD}); - } else if (lastState === KeyboardState.CLOSED) { - transition({type: Actions.CLOSE_KEYBOARD}); - } - }, - [], - ); - - const translateY = useDerivedValue(() => { - const {current, previous} = currentActionSheetState.get(); - - // We don't need to run any additional logic. it will always return 0 for idle state - if (current.state === States.IDLE) { - return withSpring(0, SPRING_CONFIG); - } - - const keyboardHeight = keyboard.height.get() === 0 ? 0 : keyboard.height.get() - safeArea.bottom; - - // Sometimes we need to know the last keyboard height - const lastKeyboardHeight = keyboard.heightWhenOpened.get() - safeArea.bottom; - const {popoverHeight = 0, fy, height} = current.payload ?? {}; - const invertedKeyboardHeight = keyboard.state.get() === KeyboardState.CLOSED ? lastKeyboardHeight : 0; - const elementOffset = fy !== undefined && height !== undefined && popoverHeight !== undefined ? fy + safeArea.top + height - (windowHeight - popoverHeight) : 0; - - // when the state is not idle we know for sure we have the previous state - const previousPayload = previous.payload ?? {}; - const previousElementOffset = - previousPayload.fy !== undefined && previousPayload.height !== undefined && previousPayload.popoverHeight !== undefined - ? previousPayload.fy + safeArea.top + previousPayload.height - (windowHeight - previousPayload.popoverHeight) - : 0; - - const isOpeningKeyboard = syncLocalWorkletState.get() === 1; - const isClosingKeyboard = syncLocalWorkletState.get() === 3; - const isClosedKeyboard = syncLocalWorkletState.get() === 4; - - // Depending on the current and sometimes previous state we can return - // either animation or just a value - switch (current.state) { - case States.KEYBOARD_OPEN: { - if (isClosedKeyboard || isOpeningKeyboard) { - return lastKeyboardHeight - keyboardHeight; - } - if (previous.state === States.KEYBOARD_CLOSED_POPOVER || (previous.state === States.KEYBOARD_OPEN && elementOffset < 0)) { - return Math.max(keyboard.heightWhenOpened.get() - keyboard.height.get() - safeArea.bottom, 0) + Math.max(elementOffset, 0); - } - return withSpring(0, SPRING_CONFIG); - } - - case States.POPOVER_CLOSED: { - return withSpring(0, SPRING_CONFIG, () => { - transition({ - type: Actions.END_TRANSITION, - }); - }); - } - - case States.POPOVER_OPEN: { - if (popoverHeight) { - if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { - return withSpring(elementOffset < 0 ? 0 : elementOffset, SPRING_CONFIG); - } - - return withSpring(Math.max(previousElementOffset, 0), SPRING_CONFIG); - } - - return 0; - } - - case States.KEYBOARD_POPOVER_OPEN: { - if (keyboard.state.get() === KeyboardState.OPEN) { - return withSpring(0, SPRING_CONFIG); - } - - const nextOffset = elementOffset + lastKeyboardHeight; - - if (keyboard.state.get() === KeyboardState.CLOSED && nextOffset > invertedKeyboardHeight) { - return withSpring(nextOffset < 0 ? 0 : nextOffset, SPRING_CONFIG); - } - - if (elementOffset < 0) { - return isClosingKeyboard ? 0 : lastKeyboardHeight - keyboardHeight; - } - - return lastKeyboardHeight; - } - - case States.KEYBOARD_CLOSED_POPOVER: { - if (elementOffset < 0) { - transition({type: Actions.END_TRANSITION}); - - return 0; - } - - if (keyboard.state.get() === KeyboardState.CLOSED) { - return elementOffset + lastKeyboardHeight; - } - - if (keyboard.height.get() > 0) { - return keyboard.heightWhenOpened.get() - keyboard.height.get() + elementOffset; - } - - return withTiming(elementOffset + lastKeyboardHeight, { - duration: 0, - }); - } - - default: - return 0; - } - }, []); - - const animatedStyle = useAnimatedStyle(() => ({ - paddingTop: translateY.get(), - })); - - return ( - - ); -} - -ActionSheetKeyboardSpace.displayName = 'ActionSheetKeyboardSpace'; - -export default ActionSheetKeyboardSpace; diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx deleted file mode 100644 index 2c40df7e61c6..000000000000 --- a/src/components/ActionSheetAwareScrollView/index.ios.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type {PropsWithChildren} from 'react'; -import React, {forwardRef} from 'react'; -import type {ScrollViewProps} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -import {ScrollView} from 'react-native'; -import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; -import ActionSheetKeyboardSpace from './ActionSheetKeyboardSpace'; - -const ActionSheetAwareScrollView = forwardRef>((props, ref) => ( - - {props.children} - -)); - -export default ActionSheetAwareScrollView; - -/** - * This function should be used as renderScrollComponent prop for FlatList - * @param props - props that will be passed to the ScrollView from FlatList - * @returns - ActionSheetAwareScrollView - */ -function renderScrollComponent(props: ScrollViewProps) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; -} - -export {renderScrollComponent, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions}; diff --git a/src/components/ActionSheetAwareScrollView/index.tsx b/src/components/ActionSheetAwareScrollView/index.tsx deleted file mode 100644 index d22f991ce4cf..000000000000 --- a/src/components/ActionSheetAwareScrollView/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// this whole file is just for other platforms -// iOS version has everything implemented -import type {PropsWithChildren} from 'react'; -import React, {forwardRef} from 'react'; -import type {ScrollViewProps} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -import {ScrollView} from 'react-native'; -import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; - -const ActionSheetAwareScrollView = forwardRef>((props, ref) => ( - - {props.children} - -)); - -export default ActionSheetAwareScrollView; - -/** - * This is only used on iOS. On other platforms it's just undefined to be pass a prop to FlatList - * - * This function should be used as renderScrollComponent prop for FlatList - * @param {Object} props - props that will be passed to the ScrollView from FlatList - * @returns {React.ReactElement} - ActionSheetAwareScrollView - */ -const renderScrollComponent = undefined; - -export {renderScrollComponent, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions}; diff --git a/src/components/DestinationPicker.tsx b/src/components/DestinationPicker.tsx new file mode 100644 index 000000000000..ea0acf4a1c58 --- /dev/null +++ b/src/components/DestinationPicker.tsx @@ -0,0 +1,90 @@ +import React, {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PerDiemRequestUtils from '@libs/PerDiemRequestUtils'; +import type {Destination} from '@libs/PerDiemRequestUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SelectionList from './SelectionList'; +import RadioListItem from './SelectionList/RadioListItem'; +import type {ListItem} from './SelectionList/types'; + +type DestinationPickerProps = { + policyID: string; + selectedDestination?: string; + onSubmit: (item: ListItem & {currency: string}) => void; +}; + +function DestinationPicker({selectedDestination, policyID, onSubmit}: DestinationPickerProps) { + const policy = usePolicy(policyID); + const customUnit = PolicyUtils.getPerDiemCustomUnit(policy); + const [policyRecentlyUsedDestinations] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS}${policyID}`); + + const {translate} = useLocalize(); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + + const selectedOptions = useMemo((): Destination[] => { + if (!selectedDestination) { + return []; + } + + const selectedRate = customUnit?.rates?.[selectedDestination]; + + if (!selectedRate?.customUnitRateID) { + return []; + } + + return [ + { + rateID: selectedRate.customUnitRateID, + name: selectedRate?.name ?? '', + currency: selectedRate?.currency ?? CONST.CURRENCY.USD, + isSelected: true, + }, + ]; + }, [customUnit?.rates, selectedDestination]); + + const [sections, headerMessage, shouldShowTextInput] = useMemo(() => { + const destinationOptions = PerDiemRequestUtils.getDestinationListSections({ + searchValue: debouncedSearchValue, + selectedOptions, + destinations: Object.values(customUnit?.rates ?? {}), + recentlyUsedDestinations: policyRecentlyUsedDestinations, + }); + + const destinationData = destinationOptions?.at(0)?.data ?? []; + const header = OptionsListUtils.getHeaderMessageForNonUserList(destinationData.length > 0, debouncedSearchValue); + const destinationsCount = Object.values(customUnit?.rates ?? {}).length; + const isDestinationsCountBelowThreshold = destinationsCount < CONST.STANDARD_LIST_ITEM_LIMIT; + const showInput = !isDestinationsCountBelowThreshold; + + return [destinationOptions, header, showInput]; + }, [debouncedSearchValue, selectedOptions, customUnit?.rates, policyRecentlyUsedDestinations]); + + const selectedOptionKey = useMemo( + () => (sections?.at(0)?.data ?? []).filter((destination) => destination.keyForList === selectedDestination).at(0)?.keyForList, + [sections, selectedDestination], + ); + + return ( + + ); +} + +DestinationPicker.displayName = 'DestinationPicker'; + +export default DestinationPicker; diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index a10e7d9fd1f3..26d1a902b475 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -1,9 +1,8 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {memo, useContext, useEffect, useRef} from 'react'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; +import React, {memo, useEffect, useRef} from 'react'; +import type {GestureResponderEvent} from 'react-native'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import type PressableProps from '@components/Pressable/GenericPressable/types'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; import useLocalize from '@hooks/useLocalize'; @@ -21,7 +20,7 @@ type EmojiPickerButtonProps = { emojiPickerID?: string; /** A callback function when the button is pressed */ - onPress?: PressableProps['onPress']; + onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** Emoji popup anchor offset shift vertical */ shiftVertical?: number; @@ -32,41 +31,12 @@ type EmojiPickerButtonProps = { }; function EmojiPickerButton({isDisabled = false, emojiPickerID = '', shiftVertical = 0, onPress, onModalHide, onEmojiSelected}: EmojiPickerButtonProps) { - const actionSheetContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const emojiPopoverAnchor = useRef(null); const {translate} = useLocalize(); const isFocused = useIsFocused(); - const openEmojiPicker: PressableProps['onPress'] = (e) => { - if (!isFocused) { - return; - } - - actionSheetContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.CLOSE_KEYBOARD, - }); - - if (!EmojiPickerAction.emojiPickerRef?.current?.isEmojiPickerVisible) { - EmojiPickerAction.showEmojiPicker( - onModalHide, - onEmojiSelected, - emojiPopoverAnchor, - { - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - shiftVertical, - }, - () => {}, - emojiPickerID, - ); - } else { - EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); - } - onPress?.(e); - }; - useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); return ( @@ -75,7 +45,28 @@ function EmojiPickerButton({isDisabled = false, emojiPickerID = '', shiftVertica ref={emojiPopoverAnchor} style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} disabled={isDisabled} - onPress={openEmojiPicker} + onPress={(e) => { + if (!isFocused) { + return; + } + if (!EmojiPickerAction.emojiPickerRef?.current?.isEmojiPickerVisible) { + EmojiPickerAction.showEmojiPicker( + onModalHide, + onEmojiSelected, + emojiPopoverAnchor, + { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + shiftVertical, + }, + () => {}, + emojiPickerID, + ); + } else { + EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); + } + onPress?.(e); + }} id={CONST.EMOJI_PICKER_BUTTON_NATIVE_ID} accessibilityLabel={translate('reportActionCompose.emoji')} > diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index d9dfbca277fc..2731d6bd1f98 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -69,9 +69,6 @@ type FormProviderProps = FormProps, @@ -193,7 +189,7 @@ function FormProvider( const submit = useDebounceNonReactive( useCallback(() => { // Return early if the form is already submitting to avoid duplicate submission - if (!!formState?.isLoading || isLoading) { + if (formState?.isLoading) { return; } @@ -214,7 +210,7 @@ function FormProvider( } KeyboardUtils.dismiss().then(() => onSubmit(trimmedStringValues)); - }, [enabledWhenOffline, formState?.isLoading, inputValues, isLoading, network?.isOffline, onSubmit, onValidate, shouldTrimValues]), + }, [enabledWhenOffline, formState?.isLoading, inputValues, network?.isOffline, onSubmit, onValidate, shouldTrimValues]), 1000, {leading: true, trailing: false}, ); @@ -410,7 +406,6 @@ function FormProvider( onSubmit={submit} inputRefs={inputRefs} errors={errors} - isLoading={isLoading} enabledWhenOffline={enabledWhenOffline} > {typeof children === 'function' ? children({inputValues}) : children} diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 7e3662e0d8d5..64bb2173f5b0 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -36,9 +36,6 @@ type FormWrapperProps = ChildrenProps & /** Callback to submit the form */ onSubmit: () => void; - - /** Whether the form is loading */ - isLoading?: boolean; }; function FormWrapper({ @@ -60,7 +57,6 @@ function FormWrapper({ shouldHideFixErrorsAlert = false, disablePressOnEnter = false, isSubmitDisabled = false, - isLoading = false, }: FormWrapperProps) { const styles = useThemeStyles(); const {paddingBottom: safeAreaInsetPaddingBottom} = useStyledSafeAreaInsets(); @@ -116,7 +112,7 @@ function FormWrapper({ buttonText={submitButtonText} isDisabled={isSubmitDisabled} isAlertVisible={((!isEmptyObject(errors) || !isEmptyObject(formState?.errorFields)) && !shouldHideFixErrorsAlert) || !!errorMessage} - isLoading={!!formState?.isLoading || isLoading} + isLoading={!!formState?.isLoading} message={isEmptyObject(formState?.errorFields) ? errorMessage : undefined} onSubmit={onSubmit} footerContent={footerContent} @@ -147,7 +143,6 @@ function FormWrapper({ formState?.isLoading, shouldHideFixErrorsAlert, errorMessage, - isLoading, onSubmit, footerContent, onFixTheErrorsLinkPressed, diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index ab9260a6b5d9..d6bcc28e09bf 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -23,6 +23,7 @@ import type StatePicker from '@components/StatePicker'; import type StateSelector from '@components/StateSelector'; import type TextInput from '@components/TextInput'; import type TextPicker from '@components/TextPicker'; +import type TimeModalPicker from '@components/TimeModalPicker'; import type UploadFile from '@components/UploadFile'; import type ValuePicker from '@components/ValuePicker'; import type ConstantSelector from '@pages/Debug/ConstantSelector'; @@ -69,7 +70,8 @@ type ValidInputs = | typeof StatePicker | typeof ConstantSelector | typeof UploadFile - | typeof PushRowWithModal; + | typeof PushRowWithModal + | typeof TimeModalPicker; type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country' | 'reportFields' | 'disabledListValues' | 'entityChart'; type ValueTypeMap = { diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 128ebd2d3a84..c27eef1de91e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -94,7 +94,7 @@ function ImageRenderer({tnode}: ImageRendererProps) { thumbnailImageComponent ) : ( - {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( {({reportID, accountID, type}) => ( - showContextMenuForReport( - event, - anchor, - report?.reportID ?? '-1', - action, - checkIfContextMenuActive, - ReportUtils.isArchivedRoom(report, reportNameValuePairs), - ), - ); + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); }} shouldUseHapticsOnLongPress accessibilityRole={CONST.ROLE.BUTTON} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 29c1d290fa5f..96bdf8e9e1e8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -84,16 +84,14 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona return ( - {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( { if (isDisabled) { return; } - return onShowContextMenu(() => - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)), - ); + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); }} onPress={(event) => { event.preventDefault(); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index b7c428e72f29..b1e5c21500f0 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -34,25 +34,16 @@ function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...d return ( - {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( {})} onPressIn={onPressIn} onPressOut={onPressOut} onLongPress={(event) => { - onShowContextMenu(() => { - if (isDisabled) { - return; - } - return showContextMenuForReport( - event, - anchor, - report?.reportID ?? '-1', - action, - checkIfContextMenuActive, - ReportUtils.isArchivedRoom(report, reportNameValuePairs), - ); - }); + if (isDisabled) { + return; + } + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); }} shouldUseHapticsOnLongPress role={CONST.ROLE.PRESENTATION} diff --git a/src/components/KeyboardAvoidingView/index.android.tsx b/src/components/KeyboardAvoidingView/index.android.tsx index e81ebd6ff671..ec2dc3bd18d7 100644 --- a/src/components/KeyboardAvoidingView/index.android.tsx +++ b/src/components/KeyboardAvoidingView/index.android.tsx @@ -1,3 +1,6 @@ +/* + * The KeyboardAvoidingView is only used on ios + */ import React from 'react'; import type {KeyboardAvoidingViewProps} from 'react-native-keyboard-controller'; import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; diff --git a/src/components/KeyboardAvoidingView/index.ios.tsx b/src/components/KeyboardAvoidingView/index.ios.tsx index e81ebd6ff671..171210eab7ac 100644 --- a/src/components/KeyboardAvoidingView/index.ios.tsx +++ b/src/components/KeyboardAvoidingView/index.ios.tsx @@ -1,6 +1,9 @@ +/* + * The KeyboardAvoidingView is only used on ios + */ import React from 'react'; -import type {KeyboardAvoidingViewProps} from 'react-native-keyboard-controller'; -import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; +import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native'; +import type {KeyboardAvoidingViewProps} from './types'; function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 00965d197937..f708d54de396 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -114,6 +114,9 @@ type MoneyRequestConfirmationListProps = { /** Whether the expense is a distance expense */ isDistanceRequest?: boolean; + /** Whether the expense is a per diem expense */ + isPerDiemRequest?: boolean; + /** Whether we're editing a split expense */ isEditingSplitBill?: boolean; @@ -151,6 +154,7 @@ function MoneyRequestConfirmationList({ iouType = CONST.IOU.TYPE.SUBMIT, iouAmount, isDistanceRequest = false, + isPerDiemRequest = false, isPolicyExpenseChat = false, iouCategory = '', shouldShowSmartScanFields = true, @@ -231,11 +235,11 @@ function MoneyRequestConfirmationList({ // A flag for showing the categories field const shouldShowCategories = (isPolicyExpenseChat || isTypeInvoice) && (!!iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {}))); - const shouldShowMerchant = shouldShowSmartScanFields && !isDistanceRequest && !isTypeSend; + const shouldShowMerchant = shouldShowSmartScanFields && !isDistanceRequest && !isTypeSend && !isPerDiemRequest; const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); - const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest); + const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest) && !isPerDiemRequest; const previousTransactionAmount = usePrevious(transaction?.amount); const previousTransactionCurrency = usePrevious(transaction?.currency); @@ -425,16 +429,16 @@ function MoneyRequestConfirmationList({ text = translate('common.next'); } } else if (isTypeTrackExpense) { - text = translate('iou.trackExpense'); + text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.splitExpense'); - } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { + } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute || isPerDiemRequest) { text = translate('iou.submitExpense'); if (iouAmount !== 0) { text = translate('iou.submitAmount', {amount: formattedAmount}); } } else { - const translationKey = isTypeSplit ? 'iou.splitAmount' : 'iou.submitAmount'; + const translationKey = isTypeSplit ? 'iou.splitAmount' : 'iou.createExpenseWithAmount'; text = translate(translationKey, {amount: formattedAmount}); } return [ @@ -443,7 +447,20 @@ function MoneyRequestConfirmationList({ value: iouType, }, ]; - }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, policy, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount, isTypeInvoice]); + }, [ + isTypeInvoice, + isTypeTrackExpense, + isTypeSplit, + iouAmount, + receiptPath, + isTypeRequest, + isDistanceRequestWithPendingRoute, + isPerDiemRequest, + iouType, + policy, + translate, + formattedAmount, + ]); const onSplitShareChange = useCallback( (accountID: number, value: number) => { @@ -762,6 +779,11 @@ function MoneyRequestConfirmationList({ return; } + if (isPerDiemRequest && (transaction.comment?.customUnit?.subRates ?? []).length === 0) { + setFormError('iou.error.invalidSubrateLength'); + return; + } + if (iouType !== CONST.IOU.TYPE.PAY) { // validate the amount for distance expenses const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode); @@ -809,6 +831,7 @@ function MoneyRequestConfirmationList({ onSendMoney, iouCurrencyCode, isDistanceRequest, + isPerDiemRequest, isDistanceRequestWithPendingRoute, iouAmount, onConfirm, @@ -916,6 +939,7 @@ function MoneyRequestConfirmationList({ iouType={iouType} isCategoryRequired={isCategoryRequired} isDistanceRequest={isDistanceRequest} + isPerDiemRequest={isPerDiemRequest} isEditingSplitBill={isEditingSplitBill} isMerchantEmpty={isMerchantEmpty} isMerchantRequired={isMerchantRequired} @@ -937,6 +961,7 @@ function MoneyRequestConfirmationList({ shouldShowCategories={shouldShowCategories} shouldShowMerchant={shouldShowMerchant} shouldShowSmartScanFields={shouldShowSmartScanFields} + shouldShowAmountField={!isPerDiemRequest} shouldShowTax={shouldShowTax} transaction={transaction} transactionID={transactionID} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 6c467145cc3c..644e00378f28 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -13,6 +13,7 @@ import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PerDiemRequestUtils from '@libs/PerDiemRequestUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; @@ -27,8 +28,10 @@ import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; +import Badge from './Badge'; import ConfirmedRoute from './ConfirmedRoute'; import MentionReportContext from './HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; +import * as Expensicons from './Icon/Expensicons'; import MenuItem from './MenuItem'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import PDFThumbnail from './PDFThumbnail'; @@ -93,6 +96,9 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if it is a distance request */ isDistanceRequest: boolean; + /** Flag indicating if it is a per diem request */ + isPerDiemRequest: boolean; + /** Flag indicating if it is editing a split bill */ isEditingSplitBill: boolean | undefined; @@ -156,6 +162,9 @@ type MoneyRequestConfirmationListFooterProps = { /** Flag indicating if the smart scan fields should be shown */ shouldShowSmartScanFields: boolean; + /** Flag indicating if the amount field should be shown */ + shouldShowAmountField?: boolean; + /** Flag indicating if the tax should be shown */ shouldShowTax: boolean; @@ -188,6 +197,7 @@ function MoneyRequestConfirmationListFooter({ iouType, isCategoryRequired, isDistanceRequest, + isPerDiemRequest, isEditingSplitBill, isMerchantEmpty, isMerchantRequired, @@ -209,6 +219,7 @@ function MoneyRequestConfirmationListFooter({ shouldShowCategories, shouldShowMerchant, shouldShowSmartScanFields, + shouldShowAmountField = true, shouldShowTax, transaction, transactionID, @@ -245,11 +256,11 @@ function MoneyRequestConfirmationListFooter({ // Determines whether the tax fields can be modified. // The tax fields can only be modified if the component is not in read-only mode // and it is not a distance request. - const canModifyTaxFields = !isReadOnly && !isDistanceRequest; + const canModifyTaxFields = !isReadOnly && !isDistanceRequest && !isPerDiemRequest; // A flag for showing the billable field const shouldShowBillable = policy?.disabledFields?.defaultBillable === false; // Do not hide fields in case of paying someone - const shouldShowAllFields = !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill; + const shouldShowAllFields = !!isPerDiemRequest || !!isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || !!isEditingSplitBill; // Calculate the formatted tax amount based on the transaction's tax amount and the IOU currency code const taxAmount = TransactionUtils.getTaxAmount(transaction, false); const formattedTaxAmount = CurrencyUtils.convertToDisplayString(taxAmount, iouCurrencyCode); @@ -258,7 +269,9 @@ function MoneyRequestConfirmationListFooter({ // Determine if the merchant error should be displayed const shouldDisplayMerchantError = isMerchantRequired && (shouldDisplayFieldError || formError === 'iou.error.invalidMerchant') && isMerchantEmpty; // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") - const shouldShowReceiptEmptyState = iouType === CONST.IOU.TYPE.SUBMIT && PolicyUtils.isPaidGroupPolicy(policy); + const shouldShowReceiptEmptyState = iouType === CONST.IOU.TYPE.SUBMIT && PolicyUtils.isPaidGroupPolicy(policy) && !isPerDiemRequest; + // The per diem custom unit + const perDiemCustomUnit = PolicyUtils.getPerDiemCustomUnit(policy); const { image: receiptImage, thumbnail: receiptThumbnail, @@ -276,7 +289,6 @@ function MoneyRequestConfirmationListFooter({ reportNameValuePairs: undefined, action: undefined, checkIfContextMenuActive: () => {}, - onShowContextMenu: () => {}, isDisabled: true, }), [], @@ -309,7 +321,7 @@ function MoneyRequestConfirmationListFooter({ errorText={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} /> ), - shouldShow: shouldShowSmartScanFields, + shouldShow: shouldShowSmartScanFields && shouldShowAmountField, isSupplementary: false, }, { @@ -592,6 +604,65 @@ function MoneyRequestConfirmationListFooter({ }, ]; + const subRates = PerDiemRequestUtils.getSubratesFields(perDiemCustomUnit, transaction); + const shouldDisplaySubrateError = + isPerDiemRequest && (shouldDisplayFieldError || formError === 'iou.error.invalidSubrateLength') && (subRates.length === 0 || (subRates.length === 1 && !subRates.at(0))); + + const subRateFields = subRates.map((field, index) => ( + { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.getRoute(action, iouType, transactionID, reportID, index, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={index === 0 && shouldDisplaySubrateError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={index === 0 && shouldDisplaySubrateError ? translate('common.error.fieldRequired') : ''} + /> + )); + + const {firstDay, tripDays, lastDay} = PerDiemRequestUtils.getTimeDifferenceIntervals(transaction); + + const badgeElements = useMemo(() => { + const badges: React.JSX.Element[] = []; + if (firstDay) { + badges.push( + , + ); + } + if (tripDays) { + badges.push( + , + ); + } + if (lastDay) { + badges.push( + , + ); + } + return badges; + }, [firstDay, lastDay, translate, tripDays]); + const primaryFields: React.JSX.Element[] = []; const supplementaryFields: React.JSX.Element[] = []; @@ -704,6 +775,46 @@ function MoneyRequestConfirmationListFooter({ )} + {isPerDiemRequest && ( + <> + { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESTINATION_EDIT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + /> + + { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME_EDIT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + numberOfLinesTitle={2} + /> + {badgeElements} + + {subRateFields} + + + )} {!isDistanceRequest && // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (receiptImage || receiptThumbnail diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 9ee06ad57e1d..b8dc71aef515 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -3,7 +3,7 @@ import lodashIsEqual from 'lodash/isEqual'; import type {ReactNode, RefObject} from 'react'; import React, {useLayoutEffect, useState} from 'react'; import {StyleSheet, View} from 'react-native'; -import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -62,9 +62,6 @@ type PopoverMenuProps = Partial & { /** Callback method fired when the user requests to close the modal */ onClose: () => void; - /** Optional callback passed to popover's children container */ - onLayout?: (e: LayoutChangeEvent) => void; - /** Callback method fired when the modal is shown */ onModalShow?: () => void; @@ -157,7 +154,6 @@ function PopoverMenu({ anchorPosition, anchorRef, onClose, - onLayout, onModalShow, headerText, fromSidebarMediumScreen, @@ -369,10 +365,7 @@ function PopoverMenu({ testID={testID} > - + {renderHeaderText()} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {renderWithConditionalWrapper(shouldUseScrollView, scrollContainerStyle, renderedMenuItems)} diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index 80b9fb1a9564..4fa58ac21ffa 100644 --- a/src/components/PopoverWithMeasuredContent.tsx +++ b/src/components/PopoverWithMeasuredContent.tsx @@ -1,5 +1,5 @@ import isEqual from 'lodash/isEqual'; -import React, {useContext, useMemo, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -8,7 +8,6 @@ import ComposerFocusManager from '@libs/ComposerFocusManager'; import PopoverWithMeasuredContentUtils from '@libs/PopoverWithMeasuredContentUtils'; import CONST from '@src/CONST'; import type {AnchorDimensions, AnchorPosition} from '@src/styles'; -import * as ActionSheetAwareScrollView from './ActionSheetAwareScrollView'; import Popover from './Popover'; import type PopoverProps from './Popover/types'; @@ -62,7 +61,6 @@ function PopoverWithMeasuredContent({ shouldEnableNewFocusManagement, ...props }: PopoverWithMeasuredContentProps) { - const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); const [popoverWidth, setPopoverWidth] = useState(popoverDimensions.width); @@ -91,22 +89,9 @@ function PopoverWithMeasuredContent({ * Measure the size of the popover's content. */ const measurePopover = ({nativeEvent}: LayoutChangeEvent) => { - const {width, height} = nativeEvent.layout; - setPopoverWidth(width); - setPopoverHeight(height); + setPopoverWidth(nativeEvent.layout.width); + setPopoverHeight(nativeEvent.layout.height); setIsContentMeasured(true); - - // it handles the case when `measurePopover` is called with values like: 192, 192.00003051757812, 192 - // if we update it, then animation in `ActionSheetAwareScrollView` may be re-running - // and we'll see unsynchronized and junky animation - if (actionSheetAwareScrollViewContext.currentActionSheetState.get().current.payload?.popoverHeight !== Math.floor(height) && height !== 0) { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, - payload: { - popoverHeight: Math.floor(height), - }, - }); - } }; const adjustedAnchorPosition = useMemo(() => { diff --git a/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx index a288396ad204..5cdbd04798cc 100644 --- a/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx +++ b/src/components/ProfilingToolMenu/BaseProfilingToolMenu.tsx @@ -1,7 +1,6 @@ import React, {useCallback, useEffect, useState} from 'react'; import DeviceInfo from 'react-native-device-info'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import {startProfiling, stopProfiling} from 'react-native-release-profiler'; import Button from '@components/Button'; import Switch from '@components/Switch'; @@ -13,6 +12,7 @@ import toggleProfileTool from '@libs/actions/ProfilingTool'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import {Memoize} from '@libs/memoize'; +import Performance from '@libs/Performance'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -20,10 +20,6 @@ import pkg from '../../../package.json'; import RNFS from './RNFS'; import Share from './Share'; -type BaseProfilingToolMenuOnyxProps = { - isProfilingInProgress: OnyxEntry; -}; - type BaseProfilingToolMenuProps = { /** Path used to save the file */ pathToBeUsed: string; @@ -31,7 +27,7 @@ type BaseProfilingToolMenuProps = { displayPath: string; /** Whether to show the share button */ showShareButton?: boolean; -} & BaseProfilingToolMenuOnyxProps; +}; function formatBytes(bytes: number, decimals = 2) { if (!+bytes) { @@ -50,7 +46,8 @@ function formatBytes(bytes: number, decimals = 2) { // WARNING: When changing this name make sure that the "scripts/symbolicate-profile.ts" script is still working! const newFileName = `Profile_trace_for_${pkg.version}.cpuprofile`; -function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = false, pathToBeUsed, displayPath}: BaseProfilingToolMenuProps) { +function BaseProfilingToolMenu({showShareButton = false, pathToBeUsed, displayPath}: BaseProfilingToolMenuProps) { + const [isProfilingInProgress] = useOnyx(ONYXKEYS.APP_PROFILING_IN_PROGRESS); const styles = useThemeStyles(); const [filePath, setFilePath] = useState(''); const [sharePath, setSharePath] = useState(''); @@ -69,12 +66,14 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = setTotalMemory(amountOfTotalMemory); setUsedMemory(amountOfUsedMemory); setMemoizeStats(Memoize.stopMonitoring()); + Performance.disableMonitoring(); }, []); const onToggleProfiling = useCallback(() => { const shouldProfiling = !isProfilingInProgress; if (shouldProfiling) { Memoize.startMonitoring(); + Performance.enableMonitoring(); startProfiling(); } else { stop(); @@ -94,6 +93,7 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = totalMemory: formatBytes(totalMemory, 2), usedMemory: formatBytes(usedMemory, 2), memoizeStats, + performance: Performance.getPerformanceMeasures(), }), [memoizeStats, totalMemory, usedMemory], ); @@ -183,8 +183,4 @@ function BaseProfilingToolMenu({isProfilingInProgress = false, showShareButton = BaseProfilingToolMenu.displayName = 'BaseProfilingToolMenu'; -export default withOnyx({ - isProfilingInProgress: { - key: ONYXKEYS.APP_PROFILING_IN_PROGRESS, - }, -})(BaseProfilingToolMenu); +export default BaseProfilingToolMenu; diff --git a/src/components/ReportActionItem/MoneyRequestAction.tsx b/src/components/ReportActionItem/MoneyRequestAction.tsx index e17c30e8bddb..af54e2940d3f 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.tsx +++ b/src/components/ReportActionItem/MoneyRequestAction.tsx @@ -51,9 +51,6 @@ type MoneyRequestActionProps = MoneyRequestActionOnyxProps & { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; - /** Callback for measuring child and running a defined callback/action later */ - onShowContextMenu?: (callback: () => void) => void; - /** Whether the IOU is hovered so we can modify its style */ isHovered?: boolean; @@ -74,7 +71,6 @@ function MoneyRequestAction({ reportID, isMostRecentIOUReportAction, contextMenuAnchor, - onShowContextMenu = () => {}, checkIfContextMenuActive = () => {}, chatReport, iouReport, @@ -133,7 +129,6 @@ function MoneyRequestAction({ isTrackExpense={isTrackExpenseAction} action={action} contextMenuAnchor={contextMenuAnchor} - onShowContextMenu={onShowContextMenu} checkIfContextMenuActive={checkIfContextMenuActive} shouldShowPendingConversionMessage={shouldShowPendingConversionMessage} onPreviewPressed={onMoneyRequestPreviewPressed} diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 4cff7abe6d0f..86196f13d662 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -60,7 +60,6 @@ function MoneyRequestPreviewContent({ onPreviewPressed, containerStyles, checkIfContextMenuActive = () => {}, - onShowContextMenu = () => {}, shouldShowPendingConversionMessage = false, isHovered = false, isWhisper = false, @@ -190,7 +189,7 @@ function MoneyRequestPreviewContent({ if (!shouldDisplayContextMenu) { return; } - onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive)); + showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive); }; const getPreviewHeaderText = (): string => { diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index 7f19120426c1..c40b45c6d2bd 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -27,9 +27,6 @@ type MoneyRequestPreviewProps = { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; - /** Callback for measuring child and running a defined callback/action later */ - onShowContextMenu?: (callback: () => void) => void; - /** Extra styles to pass to View wrapper */ containerStyles?: StyleProp; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 3b159c2e4fd5..a4ade8d77aa8 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -70,9 +70,6 @@ type ReportPreviewProps = { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; - /** Callback for measuring child and running a defined callback/action later */ - onShowContextMenu: (callback: () => void) => void; - /** Callback when the payment options popover is shown */ onPaymentOptionsShow?: () => void; @@ -98,7 +95,6 @@ function ReportPreview({ checkIfContextMenuActive = () => {}, onPaymentOptionsShow, onPaymentOptionsHide, - onShowContextMenu = () => {}, }: ReportPreviewProps) { const policy = usePolicy(policyID); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); @@ -496,7 +492,7 @@ function ReportPreview({ onPress={openReportFromPreview} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive))} + onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} shouldUseHapticsOnLongPress style={[styles.flexRow, styles.justifyContentBetween, styles.reportPreviewBox]} role="button" diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index 8c6cf3d43e3f..2ea295d16143 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -57,24 +57,11 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: () => void; - /** Callback that will do measure of necessary layout elements and run provided callback */ - onShowContextMenu: (callback: () => void) => void; - /** Style for the task preview container */ style: StyleProp; }; -function TaskPreview({ - taskReportID, - action, - contextMenuAnchor, - chatReportID, - checkIfContextMenuActive, - currentUserPersonalDetails, - onShowContextMenu, - isHovered = false, - style, -}: TaskPreviewProps) { +function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, isHovered = false, style}: TaskPreviewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -109,7 +96,7 @@ function TaskPreview({ onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(taskReportID))} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive))} + onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} shouldUseHapticsOnLongPress style={[styles.flexRow, styles.justifyContentBetween, style]} role={CONST.ROLE.BUTTON} diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx index e6a7af37b1bb..3c80b3e9168e 100644 --- a/src/components/Search/SearchRouter/SearchRouterInput.tsx +++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx @@ -106,6 +106,7 @@ function SearchRouterInput( enterKeyHint="search" accessibilityLabel={translate('search.searchPlaceholder')} disabled={disabled} + maxLength={CONST.SEARCH_QUERY_LIMIT} onSubmitEditing={onSubmit} shouldUseDisabledStyles={false} textInputContainerStyles={[styles.borderNone, styles.pb0]} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 78d4afe0e805..dabcaf90e4b2 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -67,7 +67,6 @@ function BaseSelectionList( showScrollIndicator = true, showLoadingPlaceholder = false, showConfirmButton = false, - isConfirmButtonDisabled = false, shouldUseDefaultTheme = false, shouldPreventDefaultFocusOnSelectRow = false, containerStyle, @@ -766,7 +765,7 @@ function BaseSelectionList( { captureOnInputs: true, shouldBubble: !flattenedSections.allOptions.at(focusedIndex) || focusedIndex === -1, - isActive: !disableKeyboardShortcuts && isFocused && !isConfirmButtonDisabled, + isActive: !disableKeyboardShortcuts && isFocused, }, ); @@ -849,7 +848,6 @@ function BaseSelectionList( onPress={onConfirm} pressOnEnter enterKeyEventListenerPriority={1} - isDisabled={isConfirmButtonDisabled} /> )} diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx index df9c3f9280d7..d6ce930d0ec7 100644 --- a/src/components/SelectionList/ChatListItem.tsx +++ b/src/components/SelectionList/ChatListItem.tsx @@ -51,7 +51,6 @@ function ChatListItem({ action: undefined, transactionThreadReport: undefined, checkIfContextMenuActive: () => {}, - onShowContextMenu: () => {}, isDisabled: true, }; diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 73d0ec8f8c10..594b6026fc5f 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -61,7 +61,6 @@ function ReportListItem({ canSelectMultiple, onCheckboxPress, onSelectRow, - onDismissError, onFocus, onLongPressRow, shouldSyncFocus, @@ -132,7 +131,6 @@ function ReportListItem({ canSelectMultiple={canSelectMultiple} onCheckboxPress={() => onCheckboxPress?.(transactionItem as unknown as TItem)} onSelectRow={onSelectRow} - onDismissError={onDismissError} onFocus={onFocus} onLongPressRow={onLongPressRow} shouldSyncFocus={shouldSyncFocus} @@ -153,8 +151,6 @@ function ReportListItem({ canSelectMultiple={canSelectMultiple} onSelectRow={onSelectRow} onLongPressRow={onLongPressRow} - onDismissError={onDismissError} - errors={item.errors} pendingAction={item.pendingAction} keyForList={item.keyForList} onFocus={onFocus} diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index 42bf05179bdb..2582f1fb23cc 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -18,7 +18,6 @@ function TransactionListItem({ canSelectMultiple, onSelectRow, onCheckboxPress, - onDismissError, onFocus, onLongPressRow, shouldSyncFocus, @@ -65,8 +64,6 @@ function TransactionListItem({ showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={onSelectRow} - onDismissError={onDismissError} - errors={item.errors} pendingAction={item.pendingAction} keyForList={item.keyForList} onFocus={onFocus} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 5c16543e25ef..3774821ce35f 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -485,9 +485,6 @@ type BaseSelectionListProps = Partial & { /** Whether to show the default confirm button */ showConfirmButton?: boolean; - /** Whether to show the default confirm button disabled */ - isConfirmButtonDisabled?: boolean; - /** Whether to use the default theme for the confirm button */ shouldUseDefaultTheme?: boolean; diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index ee6e7e71dd7a..6fefa987fac3 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -16,13 +16,11 @@ type ShowContextMenuContextProps = { action: OnyxEntry; transactionThreadReport?: OnyxEntry; checkIfContextMenuActive: () => void; - onShowContextMenu: (callback: () => void) => void; isDisabled: boolean; }; const ShowContextMenuContext = createContext({ anchor: null, - onShowContextMenu: (callback) => callback(), report: undefined, reportNameValuePairs: undefined, action: undefined, @@ -64,7 +62,7 @@ function showContextMenuForReport( action?.reportActionID, ReportUtils.getOriginalReportID(reportID, action), undefined, - undefined, + checkIfContextMenuActive, checkIfContextMenuActive, isArchivedRoom, ); diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index 16c29c7f51c9..cc35dc482742 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -19,6 +19,9 @@ type TabSelectorProps = MaterialTopTabBarProps & { /** Callback to register focus trap container element */ onFocusTrapContainerElementChanged?: (element: HTMLElement | null) => void; + + /** Whether to show the label when the tab is inactive */ + shouldShowLabelWhenInactive?: boolean; }; type IconAndTitle = { @@ -38,12 +41,14 @@ function getIconAndTitle(route: string, translate: LocaleContextProps['translate return {icon: Expensicons.Hashtag, title: translate('tabSelector.room')}; case CONST.TAB_REQUEST.DISTANCE: return {icon: Expensicons.Car, title: translate('common.distance')}; + case CONST.TAB_REQUEST.PER_DIEM: + return {icon: Expensicons.CalendarSolid, title: translate('common.perDiem')}; default: throw new Error(`Route ${route} has no icon nor title set.`); } } -function TabSelector({state, navigation, onTabPress = () => {}, position, onFocusTrapContainerElementChanged}: TabSelectorProps) { +function TabSelector({state, navigation, onTabPress = () => {}, position, onFocusTrapContainerElementChanged, shouldShowLabelWhenInactive = true}: TabSelectorProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -98,6 +103,7 @@ function TabSelector({state, navigation, onTabPress = () => {}, position, onFocu inactiveOpacity={inactiveOpacity} backgroundColor={backgroundColor} isActive={isActive} + shouldShowLabelWhenInactive={shouldShowLabelWhenInactive} /> ); })} diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index 274813d9a44b..81b5c75124e0 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -30,9 +30,21 @@ type TabSelectorItemProps = { /** Whether this tab is active */ isActive?: boolean; + + /** Whether to show the label when the tab is inactive */ + shouldShowLabelWhenInactive?: boolean; }; -function TabSelectorItem({icon, title = '', onPress = () => {}, backgroundColor = '', activeOpacity = 0, inactiveOpacity = 1, isActive = false}: TabSelectorItemProps) { +function TabSelectorItem({ + icon, + title = '', + onPress = () => {}, + backgroundColor = '', + activeOpacity = 0, + inactiveOpacity = 1, + isActive = false, + shouldShowLabelWhenInactive = true, +}: TabSelectorItemProps) { const styles = useThemeStyles(); const [isHovered, setIsHovered] = useState(false); @@ -40,7 +52,7 @@ function TabSelectorItem({icon, title = '', onPress = () => {}, backgroundColor setIsHovered(true)} onHoverOut={() => setIsHovered(false)} @@ -52,11 +64,13 @@ function TabSelectorItem({icon, title = '', onPress = () => {}, backgroundColor activeOpacity={styles.tabOpacity(isHovered, isActive, activeOpacity, inactiveOpacity).opacity} inactiveOpacity={styles.tabOpacity(isHovered, isActive, inactiveOpacity, activeOpacity).opacity} /> - + {(shouldShowLabelWhenInactive || isActive) && ( + + )} ); } diff --git a/src/components/TextPicker/index.tsx b/src/components/TextPicker/index.tsx index 38125f5129ed..968338391aaa 100644 --- a/src/components/TextPicker/index.tsx +++ b/src/components/TextPicker/index.tsx @@ -7,17 +7,11 @@ import CONST from '@src/CONST'; import TextSelectorModal from './TextSelectorModal'; import type {TextPickerProps} from './types'; -function TextPicker( - {value, description, placeholder = '', errorText = '', onInputChange, furtherDetails, rightLabel, disabled = false, interactive = true, ...rest}: TextPickerProps, - forwardedRef: ForwardedRef, -) { +function TextPicker({value, description, placeholder = '', errorText = '', onInputChange, furtherDetails, rightLabel, ...rest}: TextPickerProps, forwardedRef: ForwardedRef) { const styles = useThemeStyles(); const [isPickerVisible, setIsPickerVisible] = useState(false); const showPickerModal = () => { - if (disabled) { - return; - } setIsPickerVisible(true); }; @@ -36,7 +30,7 @@ function TextPicker( diff --git a/src/components/TextPicker/types.ts b/src/components/TextPicker/types.ts index dded73952f1f..e260a478e4c2 100644 --- a/src/components/TextPicker/types.ts +++ b/src/components/TextPicker/types.ts @@ -42,7 +42,7 @@ type TextPickerProps = { /** Whether to show the tooltip text */ shouldShowTooltips?: boolean; -} & Pick & +} & Pick & TextProps; export type {TextSelectorModalProps, TextPickerProps}; diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index bc062fffd787..e44d57ab18e2 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Icon from '@components/Icon'; @@ -43,16 +43,16 @@ function ThreeDotsMenu({ setPopupMenuVisible(true); }; - const hidePopoverMenu = useCallback(() => { + const hidePopoverMenu = () => { setPopupMenuVisible(false); - }, []); + }; useEffect(() => { if (!isBehindModal || !isPopupMenuVisible) { return; } hidePopoverMenu(); - }, [hidePopoverMenu, isBehindModal, isPopupMenuVisible]); + }, [isBehindModal, isPopupMenuVisible]); return ( <> diff --git a/src/components/CurrencyPicker.tsx b/src/components/TimeModalPicker.tsx similarity index 54% rename from src/components/CurrencyPicker.tsx rename to src/components/TimeModalPicker.tsx index 6d2f4826fbc5..0dfdc90ab0a9 100644 --- a/src/components/CurrencyPicker.tsx +++ b/src/components/TimeModalPicker.tsx @@ -1,42 +1,40 @@ -import type {ReactNode} from 'react'; import React, {useState} from 'react'; -import useLocalize from '@hooks/useLocalize'; +import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; -import Navigation from '@libs/Navigation/Navigation'; +import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; -import CurrencySelectionList from './CurrencySelectionList'; -import type {CurrencyListItem} from './CurrencySelectionList/types'; import HeaderWithBackButton from './HeaderWithBackButton'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import Modal from './Modal'; import ScreenWrapper from './ScreenWrapper'; +import TimePicker from './TimePicker/TimePicker'; -type CurrencyPickerProps = { +type TimeModalPickerProps = { /** Current value of the selected item */ value?: string; - /** Custom content to display in the header */ - headerContent?: ReactNode; - /** Callback when the list item is selected */ onInputChange?: (value: string, key?: string) => void; /** Form Error description */ errorText?: string; + + /** Label for the picker */ + label: string; }; -function CurrencyPicker({value, errorText, headerContent, onInputChange = () => {}}: CurrencyPickerProps) { - const {translate} = useLocalize(); - const [isPickerVisible, setIsPickerVisible] = useState(false); +function TimeModalPicker({value, errorText, label, onInputChange = () => {}}: TimeModalPickerProps) { const styles = useThemeStyles(); + const [isPickerVisible, setIsPickerVisible] = useState(false); + const currentTime = value ? DateUtils.extractTime12Hour(value) : undefined; const hidePickerModal = () => { setIsPickerVisible(false); }; - const updateInput = (item: CurrencyListItem) => { - onInputChange?.(item.currencyCode); + const updateInput = (time: string) => { + const newTime = DateUtils.combineDateAndTime(time, value ?? ''); + onInputChange?.(newTime); hidePickerModal(); }; @@ -44,8 +42,8 @@ function CurrencyPicker({value, errorText, headerContent, onInputChange = () => <> setIsPickerVisible(true)} brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={errorText} @@ -57,30 +55,29 @@ function CurrencyPicker({value, errorText, headerContent, onInputChange = () => onModalHide={hidePickerModal} hideModalContentWhileAnimating useNativeDriver - onBackdropPress={Navigation.dismissModal} > - {!!headerContent && headerContent} - + + + ); } -CurrencyPicker.displayName = 'CurrencyPicker'; -export default CurrencyPicker; +TimeModalPicker.displayName = 'TimeModalPicker'; +export default TimeModalPicker; diff --git a/src/components/Tooltip/BaseGenericTooltip/index.native.tsx b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx index b48cb55ccea1..f695ebaf0ca3 100644 --- a/src/components/Tooltip/BaseGenericTooltip/index.native.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx @@ -1,9 +1,9 @@ import {Portal} from '@gorhom/portal'; import React, {useMemo, useRef, useState} from 'react'; -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {View as RNView} from 'react-native'; -import Animated, {useAnimatedStyle} from 'react-native-reanimated'; +import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; import TransparentOverlay from '@components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -40,10 +40,12 @@ function BaseGenericTooltip({ // The width of tooltip's inner content. Has to be undefined in the beginning // as a width of 0 will cause the content to be rendered of a width of 0, // which prevents us from measuring it correctly. - const [contentMeasuredWidth, setContentMeasuredWidth] = useState(); + const [contentMeasuredWidthState, setContentMeasuredWidth] = useState(); + const contentMeasuredWidthAnimated = useSharedValue(0); // The height of tooltip's wrapper. - const [wrapperMeasuredHeight, setWrapperMeasuredHeight] = useState(); + const [wrapperMeasuredHeightState, setWrapperMeasuredHeight] = useState(); + const wrapperMeasuredHeightAnimated = useSharedValue(0); const rootWrapper = useRef(null); const StyleUtils = useStyleUtils(); @@ -58,8 +60,8 @@ function BaseGenericTooltip({ tooltipTargetWidth: targetWidth, tooltipTargetHeight: targetHeight, maxWidth, - tooltipContentWidth: contentMeasuredWidth, - tooltipWrapperHeight: wrapperMeasuredHeight, + tooltipContentWidth: contentMeasuredWidthState, + tooltipWrapperHeight: wrapperMeasuredHeightState, manualShiftHorizontal: shiftHorizontal, manualShiftVertical: shiftVertical, shouldForceRenderingBelow, @@ -75,8 +77,8 @@ function BaseGenericTooltip({ targetWidth, targetHeight, maxWidth, - contentMeasuredWidth, - wrapperMeasuredHeight, + contentMeasuredWidthState, + wrapperMeasuredHeightState, shiftHorizontal, shiftVertical, shouldForceRenderingBelow, @@ -86,7 +88,11 @@ function BaseGenericTooltip({ ); const animationStyle = useAnimatedStyle(() => { - return StyleUtils.getTooltipAnimatedStyles({tooltipContentWidth: contentMeasuredWidth, tooltipWrapperHeight: wrapperMeasuredHeight, currentSize: animation}); + return StyleUtils.getTooltipAnimatedStyles({ + tooltipContentWidth: contentMeasuredWidthAnimated.get(), + tooltipWrapperHeight: wrapperMeasuredHeightAnimated.get(), + currentSize: animation, + }); }); let content; @@ -110,20 +116,17 @@ function BaseGenericTooltip({ ref={rootWrapper} style={[rootWrapperStyle, animationStyle]} onLayout={(e) => { - const {height} = e.nativeEvent.layout; - if (height === wrapperMeasuredHeight) { + const {height, width} = e.nativeEvent.layout; + if (height === wrapperMeasuredHeightAnimated.get()) { return; } + // To avoid unnecessary re-renders of the content container when passing state values to useAnimatedStyle, + // we use SharedValue for managing content and wrapper measurements. + contentMeasuredWidthAnimated.set(width); + wrapperMeasuredHeightAnimated.set(height); + + setContentMeasuredWidth(width); setWrapperMeasuredHeight(height); - // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. - const target = e.target; - setTimeout(() => { - InteractionManager.runAfterInteractions(() => { - target.measure((x, y, width) => { - setContentMeasuredWidth(width); - }); - }); - }, CONST.ANIMATED_TRANSITION); }} > {content} diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx index e1c1a000d9bd..832b5eef45f0 100644 --- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx +++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx @@ -45,7 +45,7 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel, isDele )} {!isDeleted ? ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, onShowContextMenu}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( { - showContextMenuForReport( - event, - anchor, - report?.reportID ?? '-1', - action, - checkIfContextMenuActive, - ReportUtils.isArchivedRoom(report, reportNameValuePairs), - ); - }); + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); }} shouldUseHapticsOnLongPress > diff --git a/src/hooks/useInternationalBankAccountFormSubmit.ts b/src/hooks/useInternationalBankAccountFormSubmit.ts deleted file mode 100644 index 6042bd165070..000000000000 --- a/src/hooks/useInternationalBankAccountFormSubmit.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type {FormOnyxKeys} from '@components/Form/types'; -import type {OnyxFormKey} from '@src/ONYXKEYS'; -import ONYXKEYS from '@src/ONYXKEYS'; -import useStepFormSubmit from './useStepFormSubmit'; -import type {SubStepProps} from './useSubStep/types'; - -type UseInternationalBankAccountFormSubmitParams = Pick & { - formId?: OnyxFormKey; - fieldIds: Array>; - shouldSaveDraft: boolean; -}; - -/** - * Hook for handling submit method in Missing Personal Details substeps. - * When user is in editing mode, we should save values only when user confirms the change - * @param onNext - callback - * @param fieldIds - field IDs for particular step - * @param shouldSaveDraft - if we should save draft values - */ -export default function useInternationalBankAccountFormSubmit({onNext, fieldIds, shouldSaveDraft}: UseInternationalBankAccountFormSubmitParams) { - return useStepFormSubmit({ - formId: ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM, - onNext, - fieldIds, - shouldSaveDraft, - }); -} diff --git a/src/hooks/useRestoreInputFocus/index.android.ts b/src/hooks/useRestoreInputFocus.ts similarity index 100% rename from src/hooks/useRestoreInputFocus/index.android.ts rename to src/hooks/useRestoreInputFocus.ts diff --git a/src/hooks/useRestoreInputFocus/index.ts b/src/hooks/useRestoreInputFocus/index.ts deleted file mode 100644 index 4105455698dc..000000000000 --- a/src/hooks/useRestoreInputFocus/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const useRestoreInputFocus = (_isLostFocus: boolean) => {}; - -export default useRestoreInputFocus; diff --git a/src/hooks/useSubStep/index.ts b/src/hooks/useSubStep/index.ts index cc1c79d593d9..e59e18cf85b5 100644 --- a/src/hooks/useSubStep/index.ts +++ b/src/hooks/useSubStep/index.ts @@ -1,72 +1,48 @@ import type {ComponentType} from 'react'; -import {useCallback, useMemo, useRef, useState} from 'react'; +import {useCallback, useRef, useState} from 'react'; import type {SubStepProps, UseSubStep} from './types'; -function calculateLastIndex(bodyContentLength: number, skipSteps: number[] = []) { - let lastIndex = bodyContentLength - 1; - while (skipSteps.includes(lastIndex)) { - lastIndex -= 1; - } - - return lastIndex; -} - /** * This hook ensures uniform handling of components across different screens, enabling seamless integration and navigation through sub steps of the VBBA flow. * @param bodyContent - array of components to display in particular step * @param onFinished - callback triggered after finish last step * @param startFrom - initial index for bodyContent array * @param onNextSubStep - callback triggered after finish each step - * @param skipSteps - array of indexes to skip */ -export default function useSubStep({bodyContent, onFinished, startFrom = 0, skipSteps = [], onNextSubStep = () => {}}: UseSubStep) { +export default function useSubStep({bodyContent, onFinished, startFrom = 0, onNextSubStep = () => {}}: UseSubStep) { const [screenIndex, setScreenIndex] = useState(startFrom); const isEditing = useRef(false); - if (bodyContent.length === skipSteps.length) { - throw new Error('All steps are skipped'); - } - - const lastScreenIndex = useMemo(() => calculateLastIndex(bodyContent.length, skipSteps), [bodyContent.length, skipSteps]); - const prevScreen = useCallback(() => { - let decrementNumber = 1; - while (screenIndex - decrementNumber >= 0 && skipSteps.includes(screenIndex - decrementNumber)) { - decrementNumber += 1; - } - const prevScreenIndex = screenIndex - decrementNumber; + const prevScreenIndex = screenIndex - 1; if (prevScreenIndex < 0) { return; } setScreenIndex(prevScreenIndex); - }, [screenIndex, skipSteps]); + }, [screenIndex]); const nextScreen = useCallback( (finishData?: unknown) => { if (isEditing.current) { isEditing.current = false; - setScreenIndex(lastScreenIndex); + setScreenIndex(bodyContent.length - 1); return; } - let incrementNumber = 1; - while (screenIndex + incrementNumber < lastScreenIndex && skipSteps.includes(screenIndex + incrementNumber)) { - incrementNumber += 1; - } - const nextScreenIndex = screenIndex + incrementNumber; + const nextScreenIndex = screenIndex + 1; - if (nextScreenIndex === lastScreenIndex + 1) { + if (nextScreenIndex === bodyContent.length) { onFinished(finishData); } else { onNextSubStep(); setScreenIndex(nextScreenIndex); } }, - [screenIndex, lastScreenIndex, skipSteps, onFinished, onNextSubStep], + [screenIndex, bodyContent.length, onFinished, onNextSubStep], ); const moveTo = useCallback((step: number) => { @@ -74,15 +50,14 @@ export default function useSubStep({bodyContent, on setScreenIndex(step); }, []); - const resetScreenIndex = useCallback((newScreenIndex = 0) => { - isEditing.current = false; - setScreenIndex(newScreenIndex); + const resetScreenIndex = useCallback(() => { + setScreenIndex(0); }, []); const goToTheLastStep = useCallback(() => { isEditing.current = false; - setScreenIndex(lastScreenIndex); - }, [lastScreenIndex]); + setScreenIndex(bodyContent.length - 1); + }, [bodyContent]); // eslint-disable-next-line react-compiler/react-compiler return { diff --git a/src/hooks/useSubStep/types.ts b/src/hooks/useSubStep/types.ts index a4d28265b7f3..603534e68c15 100644 --- a/src/hooks/useSubStep/types.ts +++ b/src/hooks/useSubStep/types.ts @@ -15,9 +15,6 @@ type SubStepProps = { /** moves user to previous sub step */ prevScreen?: () => void; - - /** resets screen index to passed value */ - resetScreenIndex?: (index?: number) => void; }; type UseSubStep = { @@ -32,9 +29,6 @@ type UseSubStep = { /** index of initial sub step to display */ startFrom?: number; - - /** array of indexes to skip */ - skipSteps?: number[]; }; export type {SubStepProps, UseSubStep}; diff --git a/src/hooks/useSubscriptionPrice.ts b/src/hooks/useSubscriptionPrice.ts index 0b71fe62c7c8..3185ee1aaa47 100644 --- a/src/hooks/useSubscriptionPrice.ts +++ b/src/hooks/useSubscriptionPrice.ts @@ -4,49 +4,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import usePreferredCurrency from './usePreferredCurrency'; import useSubscriptionPlan from './useSubscriptionPlan'; -const SUBSCRIPTION_PRICES = { - [CONST.PAYMENT_CARD_CURRENCY.USD]: { - [CONST.POLICY.TYPE.CORPORATE]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 900, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1800, - }, - [CONST.POLICY.TYPE.TEAM]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 500, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1000, - }, - }, - [CONST.PAYMENT_CARD_CURRENCY.AUD]: { - [CONST.POLICY.TYPE.CORPORATE]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 1500, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 3000, - }, - [CONST.POLICY.TYPE.TEAM]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 700, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, - }, - }, - [CONST.PAYMENT_CARD_CURRENCY.GBP]: { - [CONST.POLICY.TYPE.CORPORATE]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 700, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, - }, - [CONST.POLICY.TYPE.TEAM]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 400, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 800, - }, - }, - [CONST.PAYMENT_CARD_CURRENCY.NZD]: { - [CONST.POLICY.TYPE.CORPORATE]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 1600, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 3200, - }, - [CONST.POLICY.TYPE.TEAM]: { - [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 800, - [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1600, - }, - }, -} as const; - function useSubscriptionPrice(): number { const preferredCurrency = usePreferredCurrency(); const subscriptionPlan = useSubscriptionPlan(); @@ -56,7 +13,7 @@ function useSubscriptionPrice(): number { return 0; } - return SUBSCRIPTION_PRICES[preferredCurrency][subscriptionPlan][privateSubscription.type]; + return CONST.SUBSCRIPTION_PRICES[preferredCurrency][subscriptionPlan][privateSubscription.type]; } export default useSubscriptionPrice; diff --git a/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts deleted file mode 100644 index eab78097aa05..000000000000 --- a/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {executeOnUIRuntimeSync} from 'react-native-reanimated'; - -export default executeOnUIRuntimeSync; diff --git a/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts deleted file mode 100644 index 3bc8059d8762..000000000000 --- a/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {runOnUI} from 'react-native-reanimated'; - -export default runOnUI; diff --git a/src/hooks/useWorkletStateMachine/index.ts b/src/hooks/useWorkletStateMachine/index.ts deleted file mode 100644 index cfaffe968370..000000000000 --- a/src/hooks/useWorkletStateMachine/index.ts +++ /dev/null @@ -1,180 +0,0 @@ -import {useCallback} from 'react'; -import {runOnJS, runOnUI, useSharedValue} from 'react-native-reanimated'; -import Log from '@libs/Log'; -import executeOnUIRuntimeSync from './executeOnUIRuntimeSync'; - -// When you need to debug state machine change this to true -const DEBUG_MODE = false; - -type Payload = Record; -type ActionWithPayload

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

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

= { - previous: StateHolder

; - current: StateHolder

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

(stateMachine: StateMachine, initialState: State

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

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

) => { - executeOnUIRuntimeSync(transitionWorklet)(action); - }, - [transitionWorklet], - ); - - return { - currentState, - transitionWorklet, - transition, - reset, - }; -} - -export type {ActionWithPayload, State}; -export default useWorkletStateMachine; diff --git a/src/languages/en.ts b/src/languages/en.ts index 6a3e16d0aeec..8c2aeabe1146 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -70,6 +70,7 @@ import type { FeatureNameParams, FileLimitParams, FiltersAmountBetweenParams, + FirstDayTextParams, FlightLayoverParams, FormattedMaxLengthParams, ForwardedAmountParams, @@ -89,6 +90,7 @@ import type { InvalidPropertyParams, InvalidValueParams, IssueVirtualCardParams, + LastDayTextParams, LastFourDigitsParams, LastSyncAccountingParams, LastSyncDateParams, @@ -164,6 +166,7 @@ import type { ToValidateLoginParams, TransferParams, TrialStartedTitleParams, + TripLengthTextParams, UnapprovedParams, UnapproveWithIntegrationWarningParams, UnshareParams, @@ -490,6 +493,9 @@ const translations = { skip: 'Skip', chatWithAccountManager: ({accountManagerDisplayName}: ChatWithAccountManagerParams) => `Need something specific? Chat with your account manager, ${accountManagerDisplayName}.`, chatNow: 'Chat now', + destination: 'Destination', + subrate: 'Subrate', + perDiem: 'Per diem', }, supportalNoAccess: { title: 'Not so fast', @@ -870,6 +876,7 @@ const translations = { createExpense: 'Create expense', trackExpense: 'Track expense', chooseRecipient: 'Choose recipient', + createExpenseWithAmount: ({amount}: {amount: string}) => `Create ${amount} expense`, confirmDetails: 'Confirm details', pay: 'Pay', cancelPayment: 'Cancel payment', @@ -1017,6 +1024,9 @@ const translations = { splitExpenseMultipleParticipantsErrorMessage: 'An expense cannot be split between a workspace and other members. Please update your selection.', invalidMerchant: 'Please enter a correct merchant.', atLeastOneAttendee: 'At least one attendee must be selected', + invalidQuantity: 'Please enter a valid quantity.', + quantityGreaterThanZero: 'Quantity must be greater than zero.', + invalidSubrateLength: 'There must be at least one subrate.', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up. Payment is on hold until ${submitterDisplayName} enables their wallet.`, enableWallet: 'Enable wallet', @@ -1075,6 +1085,18 @@ const translations = { attendees: 'Attendees', paymentComplete: 'Payment complete', justTrackIt: 'Just track it (don’t submit it)', + time: 'Time', + startDate: 'Start date', + endDate: 'End date', + startTime: 'Start time', + endTime: 'End time', + deleteSubrate: 'Delete subrate', + deleteSubrateConfirmation: 'Are you sure you want to delete this subrate?', + quantity: 'Quantity', + subrateSelection: 'Select a subrate and enter a quantity.', + firstDayText: ({hours}: FirstDayTextParams) => `First day: ${hours} hours`, + lastDayText: ({hours}: LastDayTextParams) => `Last day: ${hours} hours`, + tripLengthText: ({days}: TripLengthTextParams) => `Trip: ${days} full days`, }, notificationPreferencesPage: { header: 'Notification preferences', @@ -1993,15 +2015,6 @@ const translations = { ownershipPercentage: 'Please enter a valid percentage number.', }, }, - addPersonalBankAccount: { - countrySelectionStepHeader: "Where's your bank account located?", - accountDetailsStepHeader: 'What are your account details?', - accountTypeStepHeader: 'What type of account is this?', - bankInformationStepHeader: 'What are your bank details?', - accountHolderInformationStepHeader: 'What are the account holder details?', - howDoWeProtectYourData: 'How do we protect your data?', - currencyHeader: "What's your bank account's currency?", - }, addPersonalBankAccountPage: { enterPassword: 'Enter Expensify password', alreadyAdded: 'This account has already been added.', @@ -2546,7 +2559,6 @@ const translations = { displayedAs: 'Displayed as', plan: 'Plan', profile: 'Workspace profile', - perDiem: 'Per diem', bankAccount: 'Bank account', connectBankAccount: 'Connect bank account', testTransactions: 'Test transactions', @@ -2557,6 +2569,8 @@ const translations = { other: (count: number) => `${count} selected`, }), settlementFrequency: 'Settlement frequency', + setAsDefault: 'Set as default workspace', + defaultNote: `Receipts sent to ${CONST.EMAIL.RECEIPTS} will appear in this workspace.`, deleteConfirmation: 'Are you sure you want to delete this workspace?', deleteWithCardsConfirmation: 'Are you sure you want to delete this workspace? This will remove all card feeds and assigned cards.', unavailable: 'Unavailable workspace', @@ -2618,8 +2632,6 @@ const translations = { }, perDiem: { subtitle: 'Set per diem rates to control daily employee spend. ', - destination: 'Destination', - subrate: 'Subrate', amount: 'Amount', deleteRates: () => ({ one: 'Delete rate', @@ -2639,6 +2651,7 @@ const translations = { }, importPerDiemRates: 'Import per diem rates', editPerDiemRate: 'Edit per diem rate', + editPerDiemRates: 'Edit per diem rates', editDestinationSubtitle: ({destination}: EditDestinationSubtitleParams) => `Updating this destination will change it for all ${destination} per diem subrates.`, editCurrencySubtitle: ({destination}: EditDestinationSubtitleParams) => `Updating this currency will change it for all ${destination} per diem subrates.`, }, @@ -4353,8 +4366,6 @@ const translations = { onlyAvailableOnPlan: 'Per diem are only available on the Control plan, starting at ', }, pricing: { - collect: '$5 ', - amount: '$9 ', perActiveMember: 'per active member per month.', }, note: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 244543e33103..7917788c83ad 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -69,6 +69,7 @@ import type { FeatureNameParams, FileLimitParams, FiltersAmountBetweenParams, + FirstDayTextParams, FlightLayoverParams, FormattedMaxLengthParams, ForwardedAmountParams, @@ -88,6 +89,7 @@ import type { InvalidPropertyParams, InvalidValueParams, IssueVirtualCardParams, + LastDayTextParams, LastFourDigitsParams, LastSyncAccountingParams, LastSyncDateParams, @@ -163,6 +165,7 @@ import type { ToValidateLoginParams, TransferParams, TrialStartedTitleParams, + TripLengthTextParams, UnapprovedParams, UnapproveWithIntegrationWarningParams, UnshareParams, @@ -481,6 +484,9 @@ const translations = { minuteAbbreviation: 'm', chatWithAccountManager: ({accountManagerDisplayName}: ChatWithAccountManagerParams) => `¿Necesitas algo específico? Habla con tu gerente de cuenta, ${accountManagerDisplayName}.`, chatNow: 'Chatear ahora', + destination: 'Destino', + subrate: 'Subtasa', + perDiem: 'Per diem', }, supportalNoAccess: { title: 'No tan rápido', @@ -866,6 +872,7 @@ const translations = { trackExpense: 'Seguimiento de gastos', chooseRecipient: 'Elige destinatario', confirmDetails: 'Confirma los detalles', + createExpenseWithAmount: ({amount}: {amount: string}) => `Crear un gasto de ${amount}`, pay: 'Pagar', cancelPayment: 'Cancelar el pago', cancelPaymentConfirmation: '¿Estás seguro de que quieres cancelar este pago?', @@ -1015,6 +1022,9 @@ const translations = { splitExpenseMultipleParticipantsErrorMessage: 'Solo puedes dividir un gasto entre un único espacio de trabajo o con miembros individuales. Por favor, actualiza tu selección.', invalidMerchant: 'Por favor, introduce un comerciante correcto.', atLeastOneAttendee: 'Debe seleccionarse al menos un asistente', + invalidQuantity: 'Por favor, introduce una cantidad válida.', + quantityGreaterThanZero: 'La cantidad debe ser mayor que cero.', + invalidSubrateLength: 'Debe haber al menos una subtasa.', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su billetera`, enableWallet: 'Habilitar billetera', @@ -1073,6 +1083,18 @@ const translations = { attendees: 'Asistentes', paymentComplete: 'Pago completo', justTrackIt: 'Solo guardarlo (no enviarlo)', + time: 'Tiempo', + startDate: 'Fecha de inicio', + endDate: 'Fecha de finalización', + startTime: 'Hora de inicio', + endTime: 'Hora de finalización', + deleteSubrate: 'Eliminar subtasa', + deleteSubrateConfirmation: '¿Estás seguro de que deseas eliminar esta subtasa?', + quantity: 'Cantidad', + subrateSelection: 'Selecciona una subtasa e introduce una cantidad.', + firstDayText: ({hours}: FirstDayTextParams) => `Primer día: ${hours} horas`, + lastDayText: ({hours}: LastDayTextParams) => `Último día: ${hours} horas`, + tripLengthText: ({days}: TripLengthTextParams) => `Viaje: ${days} días completos`, }, notificationPreferencesPage: { header: 'Preferencias de avisos', @@ -2015,15 +2037,6 @@ const translations = { ownershipPercentage: 'Por favor, ingrese un número de porcentaje válido.', }, }, - addPersonalBankAccount: { - countrySelectionStepHeader: '¿Dónde está ubicada tu cuenta bancaria?', - accountDetailsStepHeader: '¿Cuáles son los detalles de tu cuenta?', - accountTypeStepHeader: '¿Qué tipo de cuenta es esta?', - bankInformationStepHeader: '¿Cuáles son los detalles de tu banco?', - accountHolderInformationStepHeader: '¿Cuáles son los detalles del titular de la cuenta?', - howDoWeProtectYourData: '¿Cómo protegemos tus datos?', - currencyHeader: '¿Cuál es la moneda de tu cuenta bancaria?', - }, addPersonalBankAccountPage: { enterPassword: 'Escribe tu contraseña de Expensify', alreadyAdded: 'Esta cuenta ya ha sido añadida.', @@ -2568,7 +2581,6 @@ const translations = { rules: 'Reglas', plan: 'Plan', profile: 'Perfil del espacio de trabajo', - perDiem: 'Per diem', bankAccount: 'Cuenta bancaria', displayedAs: 'Mostrado como', connectBankAccount: 'Conectar cuenta bancaria', @@ -2580,6 +2592,8 @@ const translations = { other: (count: number) => `${count} seleccionados`, }), settlementFrequency: 'Frecuencia de liquidación', + setAsDefault: 'Establecer como espacio de trabajo predeterminado', + defaultNote: `Los recibos enviados a ${CONST.EMAIL.RECEIPTS} aparecerán en este espacio de trabajo.`, deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', deleteWithCardsConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo? Se eliminarán todos los datos de las tarjetas y las tarjetas asignadas.', unavailable: 'Espacio de trabajo no disponible', @@ -2642,8 +2656,6 @@ const translations = { }, perDiem: { subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados. ', - destination: 'Destino', - subrate: 'Subtasa', amount: 'Cantidad', deleteRates: () => ({ one: 'Eliminar tasa', @@ -2663,6 +2675,7 @@ const translations = { }, importPerDiemRates: 'Importar tasas de per diem', editPerDiemRate: 'Editar la tasa de per diem', + editPerDiemRates: 'Editar las tasas de per diem', editDestinationSubtitle: ({destination}: EditDestinationSubtitleParams) => `Actualizar este destino lo modificará para todas las subtasas per diem de ${destination}.`, editCurrencySubtitle: ({destination}: EditDestinationSubtitleParams) => `Actualizar esta moneda la modificará para todas las subtasas per diem de ${destination}.`, }, @@ -4424,8 +4437,6 @@ const translations = { aboutOurPlans: 'sobre nuestros planes y precios.', }, pricing: { - collect: '$5 ', - amount: '$9 ', perActiveMember: 'por miembro activo al mes.', }, upgradeToUnlock: 'Desbloquear esta función', diff --git a/src/languages/params.ts b/src/languages/params.ts index f9ca26a3575a..969706eeeb2f 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -586,6 +586,18 @@ type ChatWithAccountManagerParams = { accountManagerDisplayName: string; }; +type FirstDayTextParams = { + hours: string; +}; + +type LastDayTextParams = { + hours: string; +}; + +type TripLengthTextParams = { + days: number; +}; + type EditDestinationSubtitleParams = { destination: string; }; @@ -802,6 +814,9 @@ export type { CompanyNameParams, CustomUnitRateParams, ChatWithAccountManagerParams, + FirstDayTextParams, + LastDayTextParams, + TripLengthTextParams, EditDestinationSubtitleParams, FlightLayoverParams, }; diff --git a/src/libs/API/parameters/BankAccountCreateCorpayParams.ts b/src/libs/API/parameters/BankAccountCreateCorpayParams.ts index c753d4c4ffb2..3c617d326009 100644 --- a/src/libs/API/parameters/BankAccountCreateCorpayParams.ts +++ b/src/libs/API/parameters/BankAccountCreateCorpayParams.ts @@ -1,5 +1,5 @@ type BankAccountCreateCorpayParams = { - type?: number; + type: number; isSavings: boolean; isWithdrawal: boolean; inputs: string; diff --git a/src/libs/API/parameters/CompleteGuidedSetupParams.ts b/src/libs/API/parameters/CompleteGuidedSetupParams.ts index febb1a47d8b6..a70ebbe5e125 100644 --- a/src/libs/API/parameters/CompleteGuidedSetupParams.ts +++ b/src/libs/API/parameters/CompleteGuidedSetupParams.ts @@ -11,6 +11,8 @@ type CompleteGuidedSetupParams = { companySize?: OnboardingCompanySize; userReportedIntegration?: OnboardingAccounting; policyID?: string; + selfDMReportID?: string; + selfDMCreatedReportActionID?: string; }; export default CompleteGuidedSetupParams; diff --git a/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts b/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts index a1228a023abe..3e02b57f9e12 100644 --- a/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts +++ b/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts @@ -1,8 +1,8 @@ type GetCorpayBankAccountFieldsParams = { countryISO: string; - currency?: string; - isWithdrawal?: boolean; - isBusinessBankAccount?: boolean; + currency: string; + isWithdrawal: boolean; + isBusinessBankAccount: boolean; }; export default GetCorpayBankAccountFieldsParams; diff --git a/src/libs/API/parameters/SetNameValuePairParams.ts b/src/libs/API/parameters/SetNameValuePairParams.ts index bc83d431224b..e09ea4cae69f 100644 --- a/src/libs/API/parameters/SetNameValuePairParams.ts +++ b/src/libs/API/parameters/SetNameValuePairParams.ts @@ -1,6 +1,6 @@ type SetNameValuePairParams = { name: string; - value: boolean; + value: boolean | string; }; export default SetNameValuePairParams; diff --git a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts index 6ef6b3712439..5b7a221a8702 100644 --- a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts +++ b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts @@ -1,6 +1,6 @@ type VerifyIdentityForBankAccountParams = { bankAccountID: number; onfidoData: string; - policyID: string; + policyID?: string; }; export default VerifyIdentityForBankAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 7b1f8a203ffc..ea2d9893cf24 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -8,6 +8,7 @@ export type {default as RestartBankAccountSetupParams} from './RestartBankAccoun export type {default as AddSchoolPrincipalParams} from './AddSchoolPrincipalParams'; export type {default as AuthenticatePusherParams} from './AuthenticatePusherParams'; export type {default as BankAccountHandlePlaidErrorParams} from './BankAccountHandlePlaidErrorParams'; +export type {default as BankAccountCreateCorpayParams} from './BankAccountCreateCorpayParams'; export type {default as BeginAppleSignInParams} from './BeginAppleSignInParams'; export type {default as BeginGoogleSignInParams} from './BeginGoogleSignInParams'; export type {default as BeginSignInParams} from './BeginSignInParams'; @@ -29,6 +30,7 @@ export type {default as ExpandURLPreviewParams} from './ExpandURLPreviewParams'; export type {default as GetMissingOnyxMessagesParams} from './GetMissingOnyxMessagesParams'; export type {default as GetNewerActionsParams} from './GetNewerActionsParams'; export type {default as GetOlderActionsParams} from './GetOlderActionsParams'; +export type {default as GetCorpayBankAccountFieldsParams} from './GetCorpayBankAccountFieldsParams'; export type {default as GetPolicyCategoriesParams} from './GetPolicyCategories'; export type {default as GetReportPrivateNoteParams} from './GetReportPrivateNoteParams'; export type {default as GetRouteParams} from './GetRouteParams'; @@ -353,8 +355,6 @@ export type {default as UpdateQuickbooksDesktopCompanyCardExpenseAccountTypePara export type {default as TogglePolicyPerDiemParams} from './TogglePolicyPerDiemParams'; export type {default as OpenPolicyPerDiemRatesPageParams} from './OpenPolicyPerDiemRatesPageParams'; export type {default as TogglePlatformMuteParams} from './TogglePlatformMuteParams'; -export type {default as GetCorpayBankAccountFieldsParams} from './GetCorpayBankAccountFieldsParams'; -export type {default as BankAccountCreateCorpayParams} from './BankAccountCreateCorpayParams'; export type {default as JoinAccessiblePolicyParams} from './JoinAccessiblePolicyParams'; export type {default as ImportPerDiemRatesParams} from './ImportPerDiemRatesParams'; export type {default as ExportPerDiemCSVParams} from './ExportPerDiemCSVParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 2980e0d822ab..7b8c6df92a22 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -440,7 +440,6 @@ const WRITE_COMMANDS = { SELF_TOUR_VIEWED: 'SelfTourViewed', UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName', UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite', - GET_CORPAY_BANK_ACCOUNT_FIELDS: 'GetCorpayBankAccountFields', BANK_ACCOUNT_CREATE_CORPAY: 'BankAccount_CreateCorpay', UPDATE_WORKSPACE_CUSTOM_UNIT: 'UpdateWorkspaceCustomUnit', VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES: 'ValidateUserAndGetAccessiblePolicies', @@ -770,7 +769,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY]: Parameters.UpdateSubscriptionAddNewUsersAutomaticallyParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_SIZE]: Parameters.UpdateSubscriptionSizeParams; [WRITE_COMMANDS.REQUEST_TAX_EXEMPTION]: null; - [WRITE_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS]: Parameters.GetCorpayBankAccountFieldsParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT]: Parameters.UpdateWorkspaceCustomUnitParams; [WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; @@ -1049,7 +1047,6 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { DISCONNECT_AS_DELEGATE: 'DisconnectAsDelegate', COMPLETE_HYBRID_APP_ONBOARDING: 'CompleteHybridAppOnboarding', CONNECT_POLICY_TO_QUICKBOOKS_DESKTOP: 'ConnectPolicyToQuickbooksDesktop', - BANK_ACCOUNT_CREATE_CORPAY: 'BankAccount_CreateCorpay', // PayMoneyRequestOnSearch only works online (pattern C) and we need to play the success sound only when the request is successful PAY_MONEY_REQUEST_ON_SEARCH: 'PayMoneyRequestOnSearch', @@ -1073,7 +1070,6 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.DISCONNECT_AS_DELEGATE]: EmptyObject; [SIDE_EFFECT_REQUEST_COMMANDS.COMPLETE_HYBRID_APP_ONBOARDING]: EmptyObject; [SIDE_EFFECT_REQUEST_COMMANDS.CONNECT_POLICY_TO_QUICKBOOKS_DESKTOP]: Parameters.ConnectPolicyToQuickBooksDesktopParams; - [SIDE_EFFECT_REQUEST_COMMANDS.BANK_ACCOUNT_CREATE_CORPAY]: Parameters.BankAccountCreateCorpayParams; [SIDE_EFFECT_REQUEST_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH]: Parameters.PayMoneyRequestOnSearchParams; }; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 5ea3fd605d6f..d9ca27556062 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -442,6 +442,14 @@ function getEndOfToday(): string { return format(date, 'yyyy-MM-dd HH:mm:ss'); } +/** + * returns {string} example: 2023-05-16 05:34:14 + */ +function getStartOfToday(): string { + const date = startOfDay(new Date()); + return format(date, 'yyyy-MM-dd HH:mm:ss'); +} + /** * returns {string} example: 2023-05-16 05:34:14 */ @@ -661,6 +669,17 @@ const isTimeAtLeastOneMinuteInFuture = ({timeString, dateTimeString}: {timeStrin return isAfter(new Date(dateToCheck), addMinutes(now, 1)); }; +/** + * Checks if the time range input is valid. + * param {String} startTime: '2023-11-14 12:24:00' + * param {String} endTime: '2023-11-14 14:24:00' + * returns {Boolean} + */ +const isValidStartEndTimeRange = ({startTime, endTime}: {startTime: string; endTime: string}): boolean => { + // Check if the combinedDate is at least one minute later than the current date and time + return isAfter(new Date(endTime), new Date(startTime)); +}; + /** * Checks if the input date is in the future compared to the reference date. * param {Date} inputDate - The date to validate. @@ -907,11 +926,13 @@ const DateUtils = { subtractMillisecondsFromDateTime, addMillisecondsFromDateTime, getEndOfToday, + getStartOfToday, getDateFromStatusType, getOneHourFromNow, extractDate, getStatusUntilDate, extractTime12Hour, + formatDateTimeTo12Hour, get12HourTimeObjectFromDate, getLocalizedTimePeriodDescription, combineDateAndTime, @@ -925,6 +946,7 @@ const DateUtils = { formatWithUTCTimeZone, getWeekEndsOn, isTimeAtLeastOneMinuteInFuture, + isValidStartEndTimeRange, formatToSupportedTimezone, getLastBusinessDayOfMonth, getFormattedDateRange, diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index fd8ad40d7501..5824a79532bd 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -520,6 +520,8 @@ function validateReportDraftProperty(key: keyof Report, value: string) { ); case 'errorFields': return validateObject>(value, {}, 'string'); + case 'errors': + return validateObject>(value, {}); case 'privateNotes': return validateObject>( value, @@ -624,6 +626,7 @@ function validateReportDraftProperty(key: keyof Report, value: string) { reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION, preview: CONST.RED_BRICK_ROAD_PENDING_ACTION, welcomeMessage: CONST.RED_BRICK_ROAD_PENDING_ACTION, + errors: CONST.RED_BRICK_ROAD_PENDING_ACTION, }); } } @@ -978,6 +981,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) return validateObject>( value, { + attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION, + subRates: CONST.RED_BRICK_ROAD_PENDING_ACTION, comment: CONST.RED_BRICK_ROAD_PENDING_ACTION, hold: CONST.RED_BRICK_ROAD_PENDING_ACTION, waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 058a0844a5ba..e05d316bd8ca 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -100,6 +100,12 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default, [SCREENS.MONEY_REQUEST.STEP_ATTENDEES]: () => require('../../../../pages/iou/request/step/IOURequestStepAttendees').default, [SCREENS.MONEY_REQUEST.STEP_UPGRADE]: () => require('../../../../pages/iou/request/step/IOURequestStepUpgrade').default, + [SCREENS.MONEY_REQUEST.STEP_DESTINATION]: () => require('../../../../pages/iou/request/step/IOURequestStepDestination').default, + [SCREENS.MONEY_REQUEST.STEP_TIME]: () => require('../../../../pages/iou/request/step/IOURequestStepTime').default, + [SCREENS.MONEY_REQUEST.STEP_SUBRATE]: () => require('../../../../pages/iou/request/step/IOURequestStepSubrate').default, + [SCREENS.MONEY_REQUEST.STEP_DESTINATION_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepDestination').default, + [SCREENS.MONEY_REQUEST.STEP_TIME_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepTime').default, + [SCREENS.MONEY_REQUEST.STEP_SUBRATE_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepSubrate').default, }); const TravelModalStackNavigator = createModalStackNavigator({ @@ -247,8 +253,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/EnablePayments/EnablePayments').default, [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/VerifyAccountPage').default, [SCREENS.SETTINGS.ADD_DEBIT_CARD]: () => require('../../../../pages/settings/Wallet/AddDebitCardPage').default, - [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/InternationalDepositAccount').default, - [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: () => require('../../../../pages/AddPersonalBankAccountPage').default, + [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../../pages/AddPersonalBankAccountPage').default, [SCREENS.SETTINGS.PROFILE.STATUS]: () => require('../../../../pages/settings/Profile/CustomStatus/StatusPage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: () => require('../../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../../pages/settings/Profile/CustomStatus/SetDatePage').default, diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index c9b43498734a..a8e72c2c82ea 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -41,6 +41,9 @@ type OnyxTabNavigatorProps = ChildrenProps & { * Together, with the `onActiveTabFocusTrapContainerElementChanged` callback, we can manage the focus trap of the tab navigator in the parent component. */ onTabBarFocusTrapContainerElementChanged?: (containerElement: HTMLElement | null) => void; + + /** Whether to show the label when the tab is inactive */ + shouldShowLabelWhenInactive?: boolean; }; // eslint-disable-next-line rulesdir/no-inline-named-export @@ -62,6 +65,7 @@ function OnyxTabNavigator({ onActiveTabFocusTrapContainerElementChanged, onTabSelected = () => {}, screenListeners, + shouldShowLabelWhenInactive = true, ...rest }: OnyxTabNavigatorProps) { // Mapping of tab name to focus trap container element @@ -90,12 +94,13 @@ function OnyxTabNavigator({ return ( ); }, - [onTabBarFocusTrapContainerElementChanged, TabBar], + [TabBar, onTabBarFocusTrapContainerElementChanged, shouldShowLabelWhenInactive], ); // If the selected tab changes, we need to update the focus trap container element of the active tab diff --git a/src/libs/Navigation/getTopmostBottomTabRoute.ts b/src/libs/Navigation/getTopmostBottomTabRoute.ts index 48a8d80f4096..231e815a0016 100644 --- a/src/libs/Navigation/getTopmostBottomTabRoute.ts +++ b/src/libs/Navigation/getTopmostBottomTabRoute.ts @@ -15,7 +15,7 @@ function getTopmostBottomTabRoute(state: State | undefined): throw new Error('BottomTabNavigator route have no routes.'); } - return {name: topmostBottomTabRoute.name as BottomTabName, params: topmostBottomTabRoute.params}; + return {name: topmostBottomTabRoute.name as BottomTabName, params: topmostBottomTabRoute.params, key: topmostBottomTabRoute.key}; } export default getTopmostBottomTabRoute; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 9d4d917311e4..28e0a30dcfc8 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -242,10 +242,6 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT, exact: true, }, - [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: { - path: ROUTES.SETTINGS_ADD_US_BANK_ACCOUNT, - exact: true, - }, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: { path: ROUTES.SETTINGS_PRONOUNS, exact: true, @@ -1236,6 +1232,11 @@ const config: LinkingOptions['config'] = { path: ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.route, exact: true, }, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'per-diem': { + path: ROUTES.MONEY_REQUEST_CREATE_TAB_PER_DIEM.route, + exact: true, + }, }, }, [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT]: ROUTES.SETTINGS_CATEGORIES_ROOT.route, @@ -1262,6 +1263,12 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_SPLIT_PAYER]: ROUTES.MONEY_REQUEST_STEP_SPLIT_PAYER.route, [SCREENS.MONEY_REQUEST.STEP_ATTENDEES]: ROUTES.MONEY_REQUEST_ATTENDEE.route, [SCREENS.MONEY_REQUEST.STEP_UPGRADE]: ROUTES.MONEY_REQUEST_UPGRADE.route, + [SCREENS.MONEY_REQUEST.STEP_DESTINATION]: ROUTES.MONEY_REQUEST_STEP_DESTINATION.route, + [SCREENS.MONEY_REQUEST.STEP_TIME]: ROUTES.MONEY_REQUEST_STEP_TIME.route, + [SCREENS.MONEY_REQUEST.STEP_SUBRATE]: ROUTES.MONEY_REQUEST_STEP_SUBRATE.route, + [SCREENS.MONEY_REQUEST.STEP_DESTINATION_EDIT]: ROUTES.MONEY_REQUEST_STEP_DESTINATION_EDIT.route, + [SCREENS.MONEY_REQUEST.STEP_TIME_EDIT]: ROUTES.MONEY_REQUEST_STEP_TIME_EDIT.route, + [SCREENS.MONEY_REQUEST.STEP_SUBRATE_EDIT]: ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.route, [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: ROUTES.IOU_SEND_ADD_BANK_ACCOUNT, [SCREENS.IOU_SEND.ADD_DEBIT_CARD]: ROUTES.IOU_SEND_ADD_DEBIT_CARD, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 7c6c568a5359..2dfbb5ea1e5f 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -175,7 +175,6 @@ type SettingsNavigatorParamList = { }; [SCREENS.SETTINGS.ADD_DEBIT_CARD]: undefined; [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: undefined; - [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: undefined; @@ -1232,6 +1231,50 @@ type MoneyRequestNavigatorParamList = { reportID: string; backTo: Routes; }; + [SCREENS.MONEY_REQUEST.STEP_DESTINATION]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; + reportID: string; + backTo: Routes | undefined; + }; + [SCREENS.MONEY_REQUEST.STEP_TIME]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; + reportID: string; + backTo: Routes | undefined; + }; + [SCREENS.MONEY_REQUEST.STEP_SUBRATE]: { + iouType: Exclude; + reportID: string; + backTo: Routes | undefined; + action: IOUAction; + pageIndex: string; + transactionID: string; + }; + [SCREENS.MONEY_REQUEST.STEP_DESTINATION_EDIT]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; + reportID: string; + backTo: Routes | undefined; + }; + [SCREENS.MONEY_REQUEST.STEP_TIME_EDIT]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; + reportID: string; + backTo: Routes | undefined; + }; + [SCREENS.MONEY_REQUEST.STEP_SUBRATE_EDIT]: { + iouType: Exclude; + reportID: string; + backTo: Routes | undefined; + action: IOUAction; + pageIndex: string; + transactionID: string; + }; }; type NewTaskNavigatorParamList = { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index b588b3dd5359..eea0c46f3712 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -108,6 +108,8 @@ type GetOptionsConfig = { action?: IOUAction; recentAttendees?: Attendee[]; shouldBoldTitleByDefault?: boolean; + shouldSeparateWorkspaceChat?: boolean; + shouldSeparateSelfDMChat?: boolean; }; type GetUserToInviteConfig = { @@ -138,6 +140,8 @@ type Options = { personalDetails: ReportUtils.OptionData[]; userToInvite: ReportUtils.OptionData | null; currentUserOption: ReportUtils.OptionData | null | undefined; + workspaceChats?: ReportUtils.OptionData[]; + selfDMChat?: ReportUtils.OptionData | undefined; }; type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; showPersonalDetails?: boolean}; @@ -165,7 +169,7 @@ type OrderReportOptionsConfig = { preferRecentExpenseReports?: boolean; }; -type ReportAndPersonalDetailOptions = Pick; +type ReportAndPersonalDetailOptions = Pick; /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can @@ -1001,6 +1005,23 @@ function orderReportOptionsWithSearch( ); } +function orderWorkspaceOptions(options: ReportUtils.OptionData[]): ReportUtils.OptionData[] { + return lodashOrderBy( + options, + [ + (option) => { + // Put default workspace on top + if (option.isPolicyExpenseChat && option.policyID === activePolicyID) { + return 0; + } + + return 1; + }, + ], + ['asc'], + ); +} + function sortComparatorReportOptionByArchivedStatus(option: ReportUtils.OptionData) { return option.private_isArchived ? 1 : 0; } @@ -1028,10 +1049,12 @@ function orderOptions(options: ReportAndPersonalDetailOptions, searchValue?: str orderedReportOptions = orderReportOptions(options.recentReports); } const orderedPersonalDetailsOptions = orderPersonalDetailsOptions(options.personalDetails); + const orderedWorkspaceChats = orderWorkspaceOptions(options?.workspaceChats ?? []); return { recentReports: orderedReportOptions, personalDetails: orderedPersonalDetailsOptions, + workspaceChats: orderedWorkspaceChats, }; } @@ -1140,6 +1163,8 @@ function getValidOptions( action, recentAttendees, shouldBoldTitleByDefault = true, + shouldSeparateSelfDMChat = false, + shouldSeparateWorkspaceChat = false, }: GetOptionsConfig = {}, ): Options { const topmostReportId = Navigation.getTopmostReportId() ?? '-1'; @@ -1215,6 +1240,12 @@ function getValidOptions( return true; }); + let workspaceChats: ReportUtils.OptionData[] = []; + + if (shouldSeparateWorkspaceChat) { + workspaceChats = allReportOptions.filter((option) => option.isOwnPolicyExpenseChat && !option.private_isArchived); + } + const allPersonalDetailsOptions = includeP2P ? options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail && (includeDomainEmail || !Str.isDomainEmail(detail.login))) : []; @@ -1300,7 +1331,7 @@ function getValidOptions( if (reportPreviewAction) { const iouReportID = ReportActionUtils.getIOUReportIDFromReportActionPreview(reportPreviewAction); - const iouReportActions = allSortedReportActions[iouReportID] ?? []; + const iouReportActions = iouReportID ? allSortedReportActions[iouReportID] ?? [] : []; const lastIOUAction = iouReportActions.find((iouAction) => iouAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); if (lastIOUAction) { reportOption.lastIOUCreationDate = lastIOUAction.lastModified; @@ -1325,6 +1356,15 @@ function getValidOptions( } const currentUserOption = allPersonalDetailsOptions.find((personalDetailsOption) => personalDetailsOption.login === currentUserLogin); + let selfDMChat: ReportUtils.OptionData | undefined; + + if (shouldSeparateWorkspaceChat) { + recentReportOptions = recentReportOptions.filter((option) => !option.isPolicyExpenseChat); + } + if (shouldSeparateSelfDMChat) { + selfDMChat = recentReportOptions.find((option) => option.isSelfDM); + recentReportOptions = recentReportOptions.filter((option) => !option.isSelfDM); + } return { personalDetails: personalDetailsOptions, @@ -1333,6 +1373,8 @@ function getValidOptions( // User to invite is generated by the search input of a user. // As this function isn't concerned with any search input yet, this is null (will be set when using filterOptions). userToInvite: null, + workspaceChats, + selfDMChat, }; } @@ -1573,6 +1615,7 @@ function formatSectionsFromSearchTerm( filteredPersonalDetails: ReportUtils.OptionData[], personalDetails: OnyxEntry = {}, shouldGetOptionDetails = false, + filteredWorkspaceChats: ReportUtils.OptionData[] = [], ): SectionForSearchTerm { // We show the selected participants at the top of the list when there is no search term or maximum number of participants has already been selected // However, if there is a search term we remove the selected participants from the top of the list unless they are part of the search results @@ -1598,8 +1641,9 @@ function formatSectionsFromSearchTerm( const selectedParticipantsWithoutDetails = selectedOptions.filter((participant) => { const accountID = participant.accountID ?? null; const isPartOfSearchTerm = getPersonalDetailSearchTerms(participant).join(' ').toLowerCase().includes(cleanSearchTerm); - const isReportInRecentReports = filteredRecentReports.some((report) => report.accountID === accountID); + const isReportInRecentReports = filteredRecentReports.some((report) => report.accountID === accountID) || filteredWorkspaceChats.some((report) => report.accountID === accountID); const isReportInPersonalDetails = filteredPersonalDetails.some((personalDetail) => personalDetail.accountID === accountID); + return isPartOfSearchTerm && !isReportInRecentReports && !isReportInPersonalDetails; }); @@ -1685,6 +1729,23 @@ function filterReports(reports: ReportUtils.OptionData[], searchTerms: string[]) return filteredReports; } +function filterWorkspaceChats(reports: ReportUtils.OptionData[], searchTerms: string[]): ReportUtils.OptionData[] { + const filteredReports = searchTerms.reduceRight( + (items, term) => + filterArrayByMatch(items, term, (item) => { + const values: string[] = []; + if (item.text) { + values.push(item.text); + } + return uniqFast(values); + }), + // We start from all unfiltered reports: + reports, + ); + + return filteredReports; +} + function filterPersonalDetails(personalDetails: ReportUtils.OptionData[], searchTerms: string[]): ReportUtils.OptionData[] { return searchTerms.reduceRight( (items, term) => @@ -1736,6 +1797,34 @@ function filterUserToInvite(options: Omit, searchValue: }); } +function filterSelfDMChat(report: ReportUtils.OptionData, searchTerms: string[]): ReportUtils.OptionData | undefined { + const isMatch = searchTerms.every((term) => { + const values: string[] = []; + + if (report.text) { + values.push(report.text); + } + if (report.login) { + values.push(report.login); + values.push(report.login.replace(CONST.EMAIL_SEARCH_REGEX, '')); + } + if (report.isThread) { + if (report.alternateText) { + values.push(report.alternateText); + } + } else if (!!report.isChatRoom || !!report.isPolicyExpenseChat) { + if (report.subtitle) { + values.push(report.subtitle); + } + } + + // Remove duplicate values and check if the term matches any value + return uniqFast(values).some((value) => value.includes(term)); + }); + + return isMatch ? report : undefined; +} + function filterOptions(options: Options, searchInputValue: string, config?: FilterUserToInviteConfig): Options { const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue))); const searchValue = parsedPhoneNumber.possible && parsedPhoneNumber.number?.e164 ? parsedPhoneNumber.number.e164 : searchInputValue.toLowerCase(); @@ -1753,12 +1842,17 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt searchValue, config, ); + const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); + + const selfDMChat = options.selfDMChat ? filterSelfDMChat(options.selfDMChat, searchTerms) : undefined; return { personalDetails, recentReports, userToInvite, currentUserOption, + workspaceChats, + selfDMChat, }; } diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts index 95fc334b906c..c18ebd217406 100644 --- a/src/libs/PaymentUtils.ts +++ b/src/libs/PaymentUtils.ts @@ -27,10 +27,10 @@ function hasExpensifyPaymentMethod(fundList: Record, bankAccountLi return validBankAccount || (shouldIncludeDebitCard && validDebitCard); } -function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData'] | ACHAccount, bankCurrency?: string): string { +function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData'] | ACHAccount): string { if (account) { if (accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT && 'accountNumber' in account) { - return `${bankCurrency} ${CONST.DOT_SEPARATOR} ${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; + return `${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; } if (accountType === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT && 'accountNumber' in account) { return `${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; @@ -61,7 +61,7 @@ function formatPaymentMethods(bankAccountList: Record, fund }); combinedPaymentMethods.push({ ...bankAccount, - description: getPaymentMethodDescription(bankAccount?.accountType, bankAccount.accountData, bankAccount.bankCurrency), + description: getPaymentMethodDescription(bankAccount?.accountType, bankAccount.accountData), icon, iconSize, iconHeight, diff --git a/src/libs/PerDiemRequestUtils.ts b/src/libs/PerDiemRequestUtils.ts new file mode 100644 index 000000000000..ad82bc606716 --- /dev/null +++ b/src/libs/PerDiemRequestUtils.ts @@ -0,0 +1,269 @@ +import {addDays, differenceInDays, differenceInMinutes, format, startOfDay} from 'date-fns'; +import lodashSortBy from 'lodash/sortBy'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, Transaction} from '@src/types/onyx'; +import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; +import * as Localize from './Localize'; +import type {OptionTree, SectionBase} from './OptionsListUtils'; +import * as PolicyUtils from './PolicyUtils'; +import * as ReportUtils from './ReportUtils'; + +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, +}); + +/** + * Returns custom unit ID for the per diem transaction + */ +function getCustomUnitID(reportID: string) { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; + const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID); + let customUnitID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; + let category: string | undefined; + + if (ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isPolicyExpenseChat(parentReport)) { + const perDiemUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); + if (perDiemUnit) { + customUnitID = perDiemUnit.customUnitID; + category = perDiemUnit.defaultCategory; + } + } + return {customUnitID, category}; +} + +type ModifiedOptionTree = OptionTree & { + currency: string; +}; + +type DestinationTreeSection = SectionBase & { + data: ModifiedOptionTree[]; + indexOffset?: number; +}; + +type Destination = { + name: string; + rateID: string; + currency: string; + isSelected?: boolean; +}; + +/** + * Builds the options for the category tree hierarchy via indents + * + * @param options - an initial object array + * @param options[].enabled - a flag to enable/disable option in a list + * @param options[].name - a name of an option + * @param options[].rateID - a rateID of an option + */ +function getDestinationOptionTree(options: Destination[]): ModifiedOptionTree[] { + const optionCollection = new Map(); + Object.values(options).forEach((option) => { + if (optionCollection.has(option.rateID)) { + return; + } + + optionCollection.set(option.rateID, { + text: option.name, + keyForList: option.rateID, + searchText: option.name, + tooltipText: option.name, + isDisabled: false, + isSelected: !!option.isSelected, + currency: option.currency, + }); + }); + + return Array.from(optionCollection.values()); +} + +/** + * Builds the section list for destinations + */ +function getDestinationListSections({ + destinations, + searchValue, + selectedOptions = [], + recentlyUsedDestinations = [], + maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, +}: { + destinations: Rate[]; + selectedOptions?: Destination[]; + searchValue?: string; + recentlyUsedDestinations?: string[]; + maxRecentReportsToShow?: number; +}): DestinationTreeSection[] { + const sortedDestinations: Destination[] = lodashSortBy(destinations, 'name').map((rate) => ({ + name: rate.name ?? '', + rateID: rate.customUnitRateID, + currency: rate.currency ?? CONST.CURRENCY.USD, + })); + const destinationSections: DestinationTreeSection[] = []; + + if (searchValue) { + const searchDestinations: Destination[] = []; + + sortedDestinations.forEach((destination) => { + if (!destination.name.toLowerCase().includes(searchValue.toLowerCase())) { + return; + } + searchDestinations.push({ + ...destination, + isSelected: selectedOptions.some((selectedOption) => selectedOption.rateID === destination.rateID), + }); + }); + + const data = getDestinationOptionTree(searchDestinations); + destinationSections.push({ + // "Search" section + title: '', + shouldShow: true, + data, + indexOffset: data.length, + }); + + return destinationSections; + } + + if (selectedOptions.length > 0) { + const data = getDestinationOptionTree(selectedOptions); + destinationSections.push({ + // "Selected" section + title: '', + shouldShow: false, + data, + indexOffset: data.length, + }); + } + + const selectedOptionRateID = selectedOptions.map((selectedOption) => selectedOption.rateID); + + if (sortedDestinations.length < CONST.STANDARD_LIST_ITEM_LIMIT) { + const data = getDestinationOptionTree(sortedDestinations); + destinationSections.push({ + // "All" section when items amount less than the threshold + title: '', + shouldShow: false, + data, + indexOffset: data.length, + }); + + return destinationSections; + } + + const filteredRecentlyUsedDestinations = sortedDestinations.filter(({rateID}) => recentlyUsedDestinations.includes(rateID) && !selectedOptionRateID.includes(rateID)); + + if (filteredRecentlyUsedDestinations.length > 0) { + const cutRecentlyUsedDestinations = filteredRecentlyUsedDestinations.slice(0, maxRecentReportsToShow); + + const data = getDestinationOptionTree(cutRecentlyUsedDestinations); + destinationSections.push({ + // "Recent" section + title: Localize.translateLocal('common.recent'), + shouldShow: true, + data, + indexOffset: data.length, + }); + } + + const data = getDestinationOptionTree(sortedDestinations); + destinationSections.push({ + // "All" section when items amount more than the threshold + title: Localize.translateLocal('common.all'), + shouldShow: true, + data, + indexOffset: data.length, + }); + + return destinationSections; +} + +function getDestinationForDisplay(customUnit: CustomUnit | undefined, transaction: OnyxEntry) { + const customUnitRateID = transaction?.comment?.customUnit?.customUnitRateID; + if (!customUnitRateID) { + return ''; + } + const selectedDestination = customUnit?.rates?.[customUnitRateID]; + return selectedDestination?.name ?? ''; +} + +function getSubratesFields(customUnit: CustomUnit | undefined, transaction: OnyxEntry) { + const customUnitRateID = transaction?.comment?.customUnit?.customUnitRateID; + if (!customUnitRateID) { + return []; + } + const selectedDestination = customUnit?.rates?.[customUnitRateID]; + const countSubrates = selectedDestination?.subRates?.length ?? 0; + const currentSubrates = transaction?.comment?.customUnit?.subRates ?? []; + if (currentSubrates.length === countSubrates) { + return currentSubrates; + } + return [...currentSubrates, undefined]; +} + +type Subrate = { + /** Key of the sub rate */ + key?: string; + + /** ID of the custom unit sub rate */ + id: string; + + /** Custom unit amount */ + quantity: number; + + /** Custom unit name */ + name: string; + + /** Custom unit rate */ + rate?: number; +}; + +function getSubratesForDisplay(subrate: Subrate | undefined) { + if (!subrate) { + return undefined; + } + return `${subrate.name}, Qty: ${subrate.quantity}`; +} + +/** + * param {string} dateTimeString + * returns {string} example: 2023-05-16 11:10 PM + */ +function formatDateTimeTo12Hour(dateTimeString: string): string { + if (!dateTimeString) { + return ''; + } + const date = new Date(dateTimeString); + return format(date, 'hh:mm a, yyyy-MM-dd'); +} + +function getTimeForDisplay(transaction: OnyxEntry) { + const customUnitRateDate = transaction?.comment?.customUnit?.attributes?.dates ?? {start: '', end: ''}; + return `${formatDateTimeTo12Hour(customUnitRateDate.start)} - ${formatDateTimeTo12Hour(customUnitRateDate.end)}`; +} + +function getTimeDifferenceIntervals(transaction: OnyxEntry) { + const customUnitRateDate = transaction?.comment?.customUnit?.attributes?.dates ?? {start: '', end: ''}; + const startDate = new Date(customUnitRateDate.start); + const endDate = new Date(customUnitRateDate.end); + const firstDayDiff = differenceInMinutes(startOfDay(addDays(startDate, 1)), startDate); + const tripDaysDiff = differenceInDays(startOfDay(endDate), startOfDay(addDays(startDate, 1))); + const lastDayDiff = differenceInMinutes(endDate, startOfDay(endDate)); + return { + firstDay: firstDayDiff === 1440 ? undefined : (firstDayDiff / 60).toFixed(2), + tripDays: firstDayDiff === 1440 ? tripDaysDiff + 1 : tripDaysDiff, + lastDay: lastDayDiff === 0 ? undefined : (lastDayDiff / 60).toFixed(2), + }; +} + +export type {Destination}; + +export {getCustomUnitID, getDestinationListSections, getDestinationForDisplay, getSubratesFields, getSubratesForDisplay, getTimeForDisplay, getTimeDifferenceIntervals}; diff --git a/src/libs/Performance.tsx b/src/libs/Performance.tsx index ef2b08e47229..df642e9f0681 100644 --- a/src/libs/Performance.tsx +++ b/src/libs/Performance.tsx @@ -3,51 +3,13 @@ import isObject from 'lodash/isObject'; import lodashTransform from 'lodash/transform'; import React, {forwardRef, Profiler} from 'react'; import {Alert, InteractionManager} from 'react-native'; -import type {PerformanceEntry, PerformanceMark, PerformanceMeasure, ReactNativePerformance, Performance as RNPerformance} from 'react-native-performance'; -import type {PerformanceObserverEntryList} from 'react-native-performance/lib/typescript/performance-observer'; +import performance, {PerformanceObserver, setResourceLoggingEnabled} from 'react-native-performance'; +import type {PerformanceEntry, PerformanceMark, PerformanceMeasure} from 'react-native-performance'; import CONST from '@src/CONST'; import isE2ETestSession from './E2E/isE2ETestSession'; import getComponentDisplayName from './getComponentDisplayName'; import * as Metrics from './Metrics'; -type WrappedComponentConfig = {id: string}; - -type PerformanceEntriesCallback = (entry: PerformanceEntry) => void; - -type Phase = 'mount' | 'update' | 'nested-update'; - -type WithRenderTraceHOC =

>(WrappedComponent: React.ComponentType

) => React.ComponentType

>; - -type BlankHOC =

>(Component: React.ComponentType

) => React.ComponentType

; - -type SetupPerformanceObserver = () => void; -type DiffObject = (object: Record, base: Record) => Record; -type GetPerformanceMetrics = () => PerformanceEntry[]; -type PrintPerformanceMetrics = () => void; -type MarkStart = (name: string, detail?: Record) => PerformanceMark | void; -type MarkEnd = (name: string, detail?: Record) => PerformanceMark | void; -type MeasureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark?: string) => void; -type MeasureTTI = (endMark?: string) => void; -type TraceRender = (id: string, phase: Phase, actualDuration: number, baseDuration: number, startTime: number, commitTime: number, interactions: Set) => PerformanceMeasure | void; -type WithRenderTrace = ({id}: WrappedComponentConfig) => WithRenderTraceHOC | BlankHOC; -type SubscribeToMeasurements = (callback: PerformanceEntriesCallback) => void; - -type PerformanceModule = { - diffObject: DiffObject; - setupPerformanceObserver: SetupPerformanceObserver; - getPerformanceMetrics: GetPerformanceMetrics; - printPerformanceMetrics: PrintPerformanceMetrics; - markStart: MarkStart; - markEnd: MarkEnd; - measureFailSafe: MeasureFailSafe; - measureTTI: MeasureTTI; - traceRender: TraceRender; - withRenderTrace: WithRenderTrace; - subscribeToMeasurements: SubscribeToMeasurements; -}; - -let rnPerformance: RNPerformance; - /** * Deep diff between two objects. Useful for figuring out what changed about an object from one render to the next so * that state and props updates can be optimized. @@ -66,204 +28,242 @@ function diffObject(object: Record, base: Record {}, - getPerformanceMetrics: () => [], - printPerformanceMetrics: () => {}, - markStart: () => {}, - markEnd: () => {}, - measureFailSafe: () => {}, - measureTTI: () => {}, - traceRender: () => {}, - withRenderTrace: - () => - // eslint-disable-next-line @typescript-eslint/naming-convention -

>(Component: React.ComponentType

): React.ComponentType

=> - Component, - subscribeToMeasurements: () => {}, -}; +function measureFailSafe(measureName: string, startOrMeasureOptions: string, endMark?: string): void { + try { + performance.measure(measureName, startOrMeasureOptions, endMark); + } catch (error) { + // Sometimes there might be no start mark recorded and the measure will fail with an error + if (error instanceof Error) { + console.debug(error.message); + } + } +} + +/** + * Measures the TTI (time to interactive) time starting from the `nativeLaunchStart` event. + * To be called when the app is considered to be interactive. + */ +function measureTTI(endMark?: string): void { + // Make sure TTI is captured when the app is really usable + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + measureFailSafe('TTI', 'nativeLaunchStart', endMark); -if (Metrics.canCapturePerformanceMetrics()) { - const perfModule = require('react-native-performance'); - perfModule.setResourceLoggingEnabled(true); - rnPerformance = perfModule.default; - - Performance.measureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark?: string) => { - try { - rnPerformance.measure(measureName, startOrMeasureOptions, endMark); - } catch (error) { - // Sometimes there might be no start mark recorded and the measure will fail with an error - if (error instanceof Error) { - console.debug(error.message); + // We don't want an alert to show: + // - on builds with performance metrics collection disabled by a feature flag + // - e2e test sessions + if (!Metrics.canCapturePerformanceMetrics() || isE2ETestSession()) { + return; } - } - }; - /** - * Measures the TTI time. To be called when the app is considered to be interactive. - */ - Performance.measureTTI = (endMark?: string) => { - // Make sure TTI is captured when the app is really usable - InteractionManager.runAfterInteractions(() => { - requestAnimationFrame(() => { - Performance.measureFailSafe('TTI', 'nativeLaunchStart', endMark); - - // we don't want the alert to show on an e2e test session - if (!isE2ETestSession()) { - Performance.printPerformanceMetrics(); - } - }); + printPerformanceMetrics(); }); - }; + }); +} - /** - * Sets up an observer to capture events recorded in the native layer before the app fully initializes. - */ - Performance.setupPerformanceObserver = () => { - // Monitor some native marks that we want to put on the timeline - new perfModule.PerformanceObserver((list: PerformanceObserverEntryList, observer: PerformanceObserver) => { - list.getEntries().forEach((entry: PerformanceEntry) => { - if (entry.name === 'nativeLaunchEnd') { - Performance.measureFailSafe('nativeLaunch', 'nativeLaunchStart', 'nativeLaunchEnd'); - } - if (entry.name === 'downloadEnd') { - Performance.measureFailSafe('jsBundleDownload', 'downloadStart', 'downloadEnd'); - } - if (entry.name === 'runJsBundleEnd') { - Performance.measureFailSafe('runJsBundle', 'runJsBundleStart', 'runJsBundleEnd'); - } - if (entry.name === 'appCreationEnd') { - Performance.measureFailSafe('appCreation', 'appCreationStart', 'appCreationEnd'); - Performance.measureFailSafe('nativeLaunchEnd_To_appCreationStart', 'nativeLaunchEnd', 'appCreationStart'); - } - if (entry.name === 'contentAppeared') { - Performance.measureFailSafe('appCreationEnd_To_contentAppeared', 'appCreationEnd', 'contentAppeared'); - } - - // We don't need to keep the observer past this point - if (entry.name === 'runJsBundleEnd' || entry.name === 'downloadEnd') { - observer.disconnect(); - } - }); - }).observe({type: 'react-native-mark', buffered: true}); - - // Monitor for "_end" marks and capture "_start" to "_end" measures - new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => { - list.getEntriesByType('mark').forEach((mark: PerformanceEntry) => { - if (mark.name.endsWith('_end')) { - const end = mark.name; - const name = end.replace(/_end$/, ''); - const start = `${name}_start`; - Performance.measureFailSafe(name, start, end); - } - - // Capture any custom measures or metrics below - if (mark.name === `${CONST.TIMING.SIDEBAR_LOADED}_end`) { - Performance.measureFailSafe('contentAppeared_To_screenTTI', 'contentAppeared', mark.name); - Performance.measureTTI(mark.name); - } - }); - }).observe({type: 'mark', buffered: true}); - }; +/* + * Monitor native marks that we want to put on the timeline + */ +const nativeMarksObserver = new PerformanceObserver((list, _observer) => { + list.getEntries().forEach((entry) => { + if (entry.name === 'nativeLaunchEnd') { + measureFailSafe('nativeLaunch', 'nativeLaunchStart', 'nativeLaunchEnd'); + } + if (entry.name === 'downloadEnd') { + measureFailSafe('jsBundleDownload', 'downloadStart', 'downloadEnd'); + } + if (entry.name === 'runJsBundleEnd') { + measureFailSafe('runJsBundle', 'runJsBundleStart', 'runJsBundleEnd'); + } + if (entry.name === 'appCreationEnd') { + measureFailSafe('appCreation', 'appCreationStart', 'appCreationEnd'); + measureFailSafe('nativeLaunchEnd_To_appCreationStart', 'nativeLaunchEnd', 'appCreationStart'); + } + if (entry.name === 'contentAppeared') { + measureFailSafe('appCreationEnd_To_contentAppeared', 'appCreationEnd', 'contentAppeared'); + } - Performance.getPerformanceMetrics = (): PerformanceEntry[] => - [ - ...rnPerformance.getEntriesByName('nativeLaunch'), - ...rnPerformance.getEntriesByName('nativeLaunchEnd_To_appCreationStart'), - ...rnPerformance.getEntriesByName('appCreation'), - ...rnPerformance.getEntriesByName('appCreationEnd_To_contentAppeared'), - ...rnPerformance.getEntriesByName('contentAppeared_To_screenTTI'), - ...rnPerformance.getEntriesByName('runJsBundle'), - ...rnPerformance.getEntriesByName('jsBundleDownload'), - ...rnPerformance.getEntriesByName('TTI'), - ...rnPerformance.getEntriesByName('regularAppStart'), - ...rnPerformance.getEntriesByName('appStartedToReady'), - ].filter((entry) => entry.duration > 0); - - /** - * Outputs performance stats. We alert these so that they are easy to access in release builds. - */ - Performance.printPerformanceMetrics = () => { - const stats = Performance.getPerformanceMetrics(); - const statsAsText = stats.map((entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n'); - - if (stats.length > 0) { - Alert.alert('Performance', statsAsText); + // At this point we've captured and processed all the native marks we're interested in + // and are not expecting to have more thus we can safely disconnect the observer + if (entry.name === 'runJsBundleEnd' || entry.name === 'downloadEnd') { + _observer.disconnect(); } - }; + }); +}); - Performance.subscribeToMeasurements = (callback: PerformanceEntriesCallback) => { - new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => { - list.getEntriesByType('measure').forEach(callback); - }).observe({type: 'measure', buffered: true}); +function setNativeMarksObserverEnabled(enabled = false): void { + if (!enabled) { + nativeMarksObserver.disconnect(); + return; + } + + nativeMarksObserver.disconnect(); + nativeMarksObserver.observe({type: 'react-native-mark', buffered: true}); +} + +/** + * Monitor for "_end" marks and capture "_start" to "_end" measures, including events recorded in the native layer before the app fully initializes. + */ +const customMarksObserver = new PerformanceObserver((list) => { + list.getEntriesByType('mark').forEach((mark) => { + if (mark.name.endsWith('_end')) { + const end = mark.name; + const name = end.replace(/_end$/, ''); + const start = `${name}_start`; + measureFailSafe(name, start, end); + } + + // Capture any custom measures or metrics below + if (mark.name === `${CONST.TIMING.SIDEBAR_LOADED}_end`) { + measureFailSafe('contentAppeared_To_screenTTI', 'contentAppeared', mark.name); + measureTTI(mark.name); + } + }); +}); + +function setCustomMarksObserverEnabled(enabled = false): void { + if (!enabled) { + customMarksObserver.disconnect(); + return; + } + + customMarksObserver.disconnect(); + customMarksObserver.observe({type: 'mark', buffered: true}); +} + +function getPerformanceMetrics(): PerformanceEntry[] { + return [ + ...performance.getEntriesByName('nativeLaunch'), + ...performance.getEntriesByName('nativeLaunchEnd_To_appCreationStart'), + ...performance.getEntriesByName('appCreation'), + ...performance.getEntriesByName('appCreationEnd_To_contentAppeared'), + ...performance.getEntriesByName('contentAppeared_To_screenTTI'), + ...performance.getEntriesByName('runJsBundle'), + ...performance.getEntriesByName('jsBundleDownload'), + ...performance.getEntriesByName('TTI'), + ...performance.getEntriesByName('regularAppStart'), + ...performance.getEntriesByName('appStartedToReady'), + ].filter((entry) => entry.duration > 0); +} + +function getPerformanceMeasures(): PerformanceEntry[] { + return performance.getEntriesByType('measure'); +} + +/** + * Outputs performance stats. We alert these so that they are easy to access in release builds. + */ +function printPerformanceMetrics(): void { + const stats = getPerformanceMetrics(); + const statsAsText = stats.map((entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n'); + + if (stats.length > 0) { + Alert.alert('Performance', statsAsText); + } +} + +function subscribeToMeasurements(callback: (entry: PerformanceEntry) => void): () => void { + const observer = new PerformanceObserver((list) => { + list.getEntriesByType('measure').forEach(callback); + }); + + observer.observe({type: 'measure', buffered: true}); + + return () => observer.disconnect(); +} + +/** + * Add a start mark to the performance entries + */ +function markStart(name: string, detail?: Record): PerformanceMark { + return performance.mark(`${name}_start`, {detail}); +} + +/** + * Add an end mark to the performance entries + * A measure between start and end is captured automatically + */ +function markEnd(name: string, detail?: Record): PerformanceMark { + return performance.mark(`${name}_end`, {detail}); +} + +type Phase = 'mount' | 'update' | 'nested-update'; + +/** + * Put data emitted by Profiler components on the timeline + * @param id the "id" prop of the Profiler tree that has just committed + * @param phase either "mount" (if the tree just mounted) or "update" (if it re-rendered) + * @param actualDuration time spent rendering the committed update + * @param baseDuration estimated time to render the entire subtree without memoization + * @param startTime when React began rendering this update + * @param commitTime when React committed this update + * @param interactions the Set of interactions belonging to this update + */ +function traceRender(id: string, phase: Phase, actualDuration: number, baseDuration: number, startTime: number, commitTime: number, interactions: Set): PerformanceMeasure { + return performance.measure(id, { + start: startTime, + duration: actualDuration, + detail: { + phase, + baseDuration, + commitTime, + interactions, + }, + }); +} + +type WrappedComponentConfig = {id: string}; + +/** + * A HOC that captures render timings of the Wrapped component + */ +function withRenderTrace({id}: WrappedComponentConfig) { + if (!Metrics.canCapturePerformanceMetrics()) { + return

>(WrappedComponent: React.ComponentType

): React.ComponentType

=> WrappedComponent; + } + + return

>(WrappedComponent: React.ComponentType

): React.ComponentType

> => { + const WithRenderTrace: React.ComponentType

> = forwardRef((props: P, ref) => ( + + + + )); + + WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent as React.ComponentType)})`; + return WithRenderTrace; }; +} - /** - * Add a start mark to the performance entries - */ - Performance.markStart = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_start`, {detail}); - - /** - * Add an end mark to the performance entries - * A measure between start and end is captured automatically - */ - Performance.markEnd = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_end`, {detail}); - - /** - * Put data emitted by Profiler components on the timeline - * @param id the "id" prop of the Profiler tree that has just committed - * @param phase either "mount" (if the tree just mounted) or "update" (if it re-rendered) - * @param actualDuration time spent rendering the committed update - * @param baseDuration estimated time to render the entire subtree without memoization - * @param startTime when React began rendering this update - * @param commitTime when React committed this update - * @param interactions the Set of interactions belonging to this update - */ - Performance.traceRender = ( - id: string, - phase: Phase, - actualDuration: number, - baseDuration: number, - startTime: number, - commitTime: number, - interactions: Set, - ): PerformanceMeasure => - rnPerformance.measure(id, { - start: startTime, - duration: actualDuration, - detail: { - phase, - baseDuration, - commitTime, - interactions, - }, - }); +function enableMonitoring() { + setResourceLoggingEnabled(true); + setNativeMarksObserverEnabled(true); + setCustomMarksObserverEnabled(true); +} - /** - * A HOC that captures render timings of the Wrapped component - */ - Performance.withRenderTrace = - ({id}: WrappedComponentConfig) => - // eslint-disable-next-line @typescript-eslint/naming-convention -

>(WrappedComponent: React.ComponentType

): React.ComponentType

> => { - const WithRenderTrace: React.ComponentType

> = forwardRef((props: P, ref) => ( - - - - )); - - WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent as React.ComponentType)})`; - return WithRenderTrace; - }; +function disableMonitoring() { + setResourceLoggingEnabled(false); + setNativeMarksObserverEnabled(false); + setCustomMarksObserverEnabled(false); } -export default Performance; +export default { + diffObject, + measureFailSafe, + measureTTI, + enableMonitoring, + disableMonitoring, + getPerformanceMetrics, + getPerformanceMeasures, + printPerformanceMetrics, + subscribeToMeasurements, + markStart, + markEnd, + withRenderTrace, +}; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index f9bf53bdd362..1ab0807c1900 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -42,6 +42,10 @@ function canUseLinkPreviews(): boolean { return false; } +function canUseMergeAccounts(betas: OnyxEntry): boolean { + return !!betas?.includes(CONST.BETAS.NEWDOT_MERGE_ACCOUNTS) || canUseAllBetas(betas); +} + export default { canUseDefaultRooms, canUseLinkPreviews, @@ -50,4 +54,5 @@ export default { canUseCombinedTrackSubmit, canUseCategoryAndTagApprovers, canUsePerDiem, + canUseMergeAccounts, }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 91381a0b0119..2a4168a24668 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -257,7 +257,7 @@ const isPolicyEmployee = (policyID: string | undefined, policies: OnyxCollection /** * Checks if the current user is an owner (creator) of the policy. */ -const isPolicyOwner = (policy: OnyxInputOrEntry, currentUserAccountID: number): boolean => policy?.ownerAccountID === currentUserAccountID; +const isPolicyOwner = (policy: OnyxInputOrEntry, currentUserAccountID: number | undefined): boolean => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID; /** * Create an object mapping member emails to their accountIDs. Filter for members without errors if includeMemberWithErrors is false, and get the login email from the personalDetail object using the accountID. @@ -420,7 +420,7 @@ function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry): PolicyEmployee[] { /** * Returns the policy of the report */ -function getPolicy(policyID: string | undefined): OnyxEntry { - if (!allPolicies || !policyID) { +function getPolicy(policyID: string | undefined, policies: OnyxCollection = allPolicies): OnyxEntry { + if (!policies || !policyID) { return undefined; } - return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + return policies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; } /** Return active policies where current user is an admin */ @@ -676,6 +676,12 @@ function canSendInvoiceFromWorkspace(policyID: string | undefined): boolean { return policy?.areInvoicesEnabled ?? false; } +/** Whether the user can submit per diem expense from the workspace */ +function canSubmitPerDiemExpenseFromWorkspace(policy: OnyxEntry): boolean { + const perDiemCustomUnit = getPerDiemCustomUnit(policy); + return !isEmptyObject(perDiemCustomUnit) && !!perDiemCustomUnit?.enabled; +} + /** Whether the user can send invoice */ function canSendInvoice(policies: OnyxCollection | null, currentUserLogin: string | undefined): boolean { return getActiveAdminWorkspaces(policies, currentUserLogin).some((policy) => canSendInvoiceFromWorkspace(policy.id)); @@ -1241,6 +1247,7 @@ export { getActiveAdminWorkspaces, getOwnedPaidPolicies, canSendInvoiceFromWorkspace, + canSubmitPerDiemExpenseFromWorkspace, canSendInvoice, hasWorkspaceWithInvoices, hasDependentTags, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 4fa704944bc9..c1f4057199ee 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -286,7 +286,7 @@ function isWhisperActionTargetedToOthers(reportAction: OnyxInputOrEntry): reportAction is ReportAction { @@ -347,7 +347,7 @@ function isInviteOrRemovedAction( /** * Returns whether the comment is a thread parent message/the first message in a thread */ -function isThreadParentMessage(reportAction: OnyxEntry, reportID: string): boolean { +function isThreadParentMessage(reportAction: OnyxEntry, reportID: string | undefined): boolean { const {childType, childVisibleActionCount = 0, childReportID} = reportAction ?? {}; return childType === CONST.REPORT.TYPE.CHAT && (childVisibleActionCount > 0 || String(childReportID) === reportID); } @@ -771,7 +771,11 @@ function replaceBaseURLInPolicyChangeLogAction(reportAction: ReportAction): Repo return updatedReportAction; } -function getLastVisibleAction(reportID: string, canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}): OnyxEntry { +function getLastVisibleAction( + reportID: string | undefined, + canUserPerformWriteAction?: boolean, + actionsToMerge: Record | null> = {}, +): OnyxEntry { let reportActions: Array = []; if (!isEmpty(actionsToMerge)) { reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge ?? {}, true)) as Array< @@ -801,7 +805,7 @@ function formatLastMessageText(lastMessageText: string) { } function getLastVisibleMessage( - reportID: string, + reportID: string | undefined, canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}, reportAction: OnyxInputOrEntry | undefined = undefined, @@ -893,12 +897,12 @@ function getLastClosedReportAction(reportActions: OnyxEntry): Ony * 4. We will get the second last action from filtered actions because the last * action is always the created action */ -function getFirstVisibleReportActionID(sortedReportActions: ReportAction[] = [], isOffline = false): string { +function getFirstVisibleReportActionID(sortedReportActions: ReportAction[] = [], isOffline = false): string | undefined { if (!Array.isArray(sortedReportActions)) { return ''; } const sortedFilterReportActions = sortedReportActions.filter((action) => !isDeletedAction(action) || (action?.childVisibleActionCount ?? 0) > 0 || isOffline); - return sortedFilterReportActions.length > 1 ? sortedFilterReportActions.at(sortedFilterReportActions.length - 2)?.reportActionID ?? '-1' : ''; + return sortedFilterReportActions.length > 1 ? sortedFilterReportActions.at(sortedFilterReportActions.length - 2)?.reportActionID : undefined; } /** @@ -991,8 +995,8 @@ function getReportPreviewAction(chatReportID: string | undefined, iouReportID: s /** * Get the iouReportID for a given report action. */ -function getIOUReportIDFromReportActionPreview(reportAction: OnyxEntry): string { - return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) ? getOriginalMessage(reportAction)?.linkedReportID ?? '-1' : '-1'; +function getIOUReportIDFromReportActionPreview(reportAction: OnyxEntry): string | undefined { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) ? getOriginalMessage(reportAction)?.linkedReportID : undefined; } /** @@ -1057,7 +1061,11 @@ const iouRequestTypes = new Set>([ * Gets the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions. * Returns a reportID if there is exactly one transaction thread for the report, and null otherwise. */ -function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): string | undefined { +function getOneTransactionThreadReportID( + reportID: string | undefined, + reportActions: OnyxEntry | ReportAction[], + isOffline: boolean | undefined = undefined, +): string | undefined { // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report. const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) { @@ -1126,7 +1134,7 @@ function doesReportHaveVisibleActions(reportID: string, canUserPerformWriteActio return visibleReportActionsWithoutTaskSystemMessage.length > 0; } -function getAllReportActions(reportID: string): ReportActions { +function getAllReportActions(reportID: string | undefined): ReportActions { return allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}; } @@ -1500,7 +1508,7 @@ function isReportActionUnread(reportAction: OnyxEntry, lastReadTim */ function isCurrentActionUnread(report: OnyxEntry, reportAction: ReportAction): boolean { const lastReadTime = report?.lastReadTime ?? ''; - const sortedReportActions = getSortedReportActions(Object.values(getAllReportActions(report?.reportID ?? '-1'))); + const sortedReportActions = getSortedReportActions(Object.values(getAllReportActions(report?.reportID))); const currentActionIndex = sortedReportActions.findIndex((action) => action.reportActionID === reportAction.reportActionID); if (currentActionIndex === -1) { return false; @@ -1560,7 +1568,8 @@ function getDismissedViolationMessageText(originalMessage: ReportAction) { @@ -1577,7 +1586,7 @@ function didMessageMentionCurrentUser(reportAction: OnyxInputOrEntry'); + return accountIDsFromMessage.includes(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID) || emailsFromMessage.includes(currentEmail) || message.includes(''); } /** @@ -1592,9 +1601,9 @@ function wasActionTakenByCurrentUser(reportAction: OnyxInputOrEntry { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const reportActions = getAllReportActions(report?.reportID ?? ''); + const reportActions = getAllReportActions(report?.reportID); const action = Object.values(reportActions ?? {})?.find((reportAction) => { - const IOUTransactionID = isMoneyRequestAction(reportAction) ? getOriginalMessage(reportAction)?.IOUTransactionID : -1; + const IOUTransactionID = isMoneyRequestAction(reportAction) ? getOriginalMessage(reportAction)?.IOUTransactionID : undefined; return IOUTransactionID === transactionID; }); return action; @@ -1641,7 +1650,7 @@ function getExportIntegrationActionFragments(reportAction: OnyxEntry '2022-11-14'; const base62ReportID = getBase62ReportID(Number(reportID)); @@ -1775,7 +1784,7 @@ function getRenamedAction(reportAction: OnyxEntry>) { const originalMessage = getOriginalMessage(reportAction); - const submittersNames = PersonalDetailsUtils.getPersonalDetailsByIDs(originalMessage?.submittersAccountIDs ?? [], currentUserAccountID ?? -1).map( + const submittersNames = PersonalDetailsUtils.getPersonalDetailsByIDs(originalMessage?.submittersAccountIDs ?? [], currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID).map( ({displayName, login}) => displayName ?? login ?? 'Unknown Submitter', ); return Localize.translateLocal('workspaceActions.removedFromApprovalWorkflow', {submittersNames, count: submittersNames.length}); @@ -1802,9 +1811,9 @@ function getCardIssuedMessage(reportAction: OnyxEntry, shouldRende ? getOriginalMessage(reportAction) : undefined; - const assigneeAccountID = cardIssuedActionOriginalMessage?.assigneeAccountID ?? -1; - const cardID = cardIssuedActionOriginalMessage?.cardID ?? -1; - const assigneeDetails = PersonalDetailsUtils.getPersonalDetailsByIDs([assigneeAccountID], currentUserAccountID ?? -1).at(0); + const assigneeAccountID = cardIssuedActionOriginalMessage?.assigneeAccountID ?? CONST.DEFAULT_NUMBER_ID; + const cardID = cardIssuedActionOriginalMessage?.cardID ?? CONST.DEFAULT_NUMBER_ID; + const assigneeDetails = PersonalDetailsUtils.getPersonalDetailsByIDs([assigneeAccountID], currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID).at(0); const isPolicyAdmin = PolicyUtils.isPolicyAdmin(PolicyUtils.getPolicy(policyID)); const assignee = shouldRenderHTML ? `` : assigneeDetails?.firstName ?? assigneeDetails?.login ?? ''; const navigateRoute = isPolicyAdmin ? ROUTES.EXPENSIFY_CARD_DETAILS.getRoute(policyID, String(cardID)) : ROUTES.SETTINGS_DOMAINCARD_DETAIL.getRoute(String(cardID)); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 589c5f1a6adc..ff6b8d633447 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1,7 +1,6 @@ import {format} from 'date-fns'; import {Str} from 'expensify-common'; import lodashEscape from 'lodash/escape'; -import lodashFindLastIndex from 'lodash/findLastIndex'; import lodashIntersection from 'lodash/intersection'; import isEmpty from 'lodash/isEmpty'; import lodashIsEqual from 'lodash/isEqual'; @@ -612,7 +611,9 @@ let currentUserPersonalDetails: OnyxEntry; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (value) => { - currentUserPersonalDetails = value?.[currentUserAccountID ?? -1] ?? undefined; + if (currentUserAccountID) { + currentUserPersonalDetails = value?.[currentUserAccountID] ?? undefined; + } allPersonalDetails = value ?? {}; allPersonalDetailLogins = Object.values(allPersonalDetails).map((personalDetail) => personalDetail?.login ?? ''); }, @@ -787,6 +788,14 @@ function getReportOrDraftReport(reportID: string | undefined): OnyxEntry return allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? allReportsDraft?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportID}`]; } +function getReportTransactions(reportID: string | undefined): Transaction[] { + if (!reportID) { + return []; + } + + return reportsTransactions[reportID] ?? []; +} + /** * Check if a report is a draft report */ @@ -799,7 +808,7 @@ function isDraftReport(reportID: string | undefined): boolean { /** * Returns the report */ -function getReport(reportID: string): OnyxEntry { +function getReport(reportID: string | undefined): OnyxEntry { return allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; } @@ -807,7 +816,7 @@ function getReport(reportID: string): OnyxEntry { * Returns the report */ function getReportNameValuePairs(reportID?: string): OnyxEntry { - return allReportNameValuePair?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID ?? -1}`]; + return allReportNameValuePair?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; } /** @@ -1051,8 +1060,8 @@ function isSettled(reportOrID: OnyxInputOrEntry | SearchReport | string /** * Whether the current user is the submitter of the report */ -function isCurrentUserSubmitter(reportID: string): boolean { - if (!allReports) { +function isCurrentUserSubmitter(reportID: string | undefined): boolean { + if (!allReports || !reportID) { return false; } const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; @@ -1308,7 +1317,8 @@ function getDefaultNotificationPreferenceForReport(report: OnyxEntry): V * Get the notification preference given a report. This should ALWAYS default to 'hidden'. Do not change this! */ function getReportNotificationPreference(report: OnyxEntry): ValueOf { - return report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const participant = currentUserAccountID ? report?.participants?.[currentUserAccountID] : undefined; + return participant?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; } const CONCIERGE_ACCOUNT_ID_STRING = CONST.ACCOUNT_ID.CONCIERGE.toString(); @@ -1643,7 +1653,11 @@ function isPolicyExpenseChatAdmin(report: OnyxEntry, policies: OnyxColle /** * Checks if the current user is the admin of the policy. */ -function isPolicyAdmin(policyID: string, policies: OnyxCollection): boolean { +function isPolicyAdmin(policyID: string | undefined, policies: OnyxCollection): boolean { + if (!policyID) { + return false; + } + const policyRole = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.role; return policyRole === CONST.POLICY.ROLE.ADMIN; @@ -1653,7 +1667,7 @@ function isPolicyAdmin(policyID: string, policies: OnyxCollection): bool * Checks whether all the transactions linked to the IOU report are of the Distance Request type with pending routes */ function hasOnlyTransactionsWithPendingRoutes(iouReportID: string | undefined): boolean { - const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + const transactions = getReportTransactions(iouReportID); // Early return false in case not having any transaction if (!transactions || transactions.length === 0) { @@ -1737,11 +1751,7 @@ function isMoneyRequestReport(reportOrID: OnyxInputOrEntry | SearchRepor * Checks if a report contains only Non-Reimbursable transactions */ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): boolean { - if (!iouReportID) { - return false; - } - - const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + const transactions = getReportTransactions(iouReportID); if (!transactions || transactions.length === 0) { return false; } @@ -1752,7 +1762,7 @@ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): bo /** * Checks if a report has only one transaction associated with it */ -function isOneTransactionReport(reportID: string): boolean { +function isOneTransactionReport(reportID: string | undefined): boolean { const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? ([] as ReportAction[]); return ReportActionsUtils.getOneTransactionThreadReportID(reportID, reportActions) !== null; } @@ -1786,9 +1796,9 @@ function isOneTransactionThread(reportID: string | undefined, parentReportID: st */ function getDisplayedReportID(reportID: string): string { const report = getReport(reportID); - const parentReportID = report?.parentReportID ?? ''; - const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, report?.parentReportActionID ?? ''); - return isOneTransactionThread(reportID, parentReportID, parentReportAction) ? parentReportID : reportID; + const parentReportID = report?.parentReportID; + const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, report?.parentReportActionID); + return parentReportID && isOneTransactionThread(reportID, parentReportID, parentReportAction) ? parentReportID : reportID; } /** @@ -1797,7 +1807,8 @@ function getDisplayedReportID(reportID: string): string { */ function isOneOnOneChat(report: OnyxEntry): boolean { const participants = report?.participants ?? {}; - const isCurrentUserParticipant = participants[currentUserAccountID ?? 0] ? 1 : 0; + const participant = currentUserAccountID ? participants[currentUserAccountID] : undefined; + const isCurrentUserParticipant = participant ? 1 : 0; const participantAmount = Object.keys(participants).length - isCurrentUserParticipant; if (participantAmount !== 1) { return false; @@ -2381,6 +2392,8 @@ function getIcons( policy?: OnyxInputOrEntry, invoiceReceiverPolicy?: OnyxInputOrEntry, ): Icon[] { + const ownerDetails = report?.ownerAccountID ? personalDetails?.[report.ownerAccountID] : undefined; + if (isEmptyObject(report)) { const fallbackIcon: Icon = { source: defaultIcon ?? FallbackAvatar, @@ -2393,12 +2406,13 @@ function getIcons( if (isExpenseRequest(report)) { const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; const workspaceIcon = getWorkspaceIcon(report, policy); + const actorDetails = parentReportAction?.actorAccountID ? personalDetails?.[parentReportAction.actorAccountID] : undefined; const memberIcon = { - source: personalDetails?.[parentReportAction?.actorAccountID ?? -1]?.avatar ?? FallbackAvatar, + source: actorDetails?.avatar ?? FallbackAvatar, id: parentReportAction?.actorAccountID, type: CONST.ICON_TYPE_AVATAR, - name: personalDetails?.[parentReportAction?.actorAccountID ?? -1]?.displayName ?? '', - fallbackIcon: personalDetails?.[parentReportAction?.actorAccountID ?? -1]?.fallbackIcon, + name: actorDetails?.displayName ?? '', + fallbackIcon: actorDetails?.fallbackIcon, }; return [memberIcon, workspaceIcon]; @@ -2407,13 +2421,14 @@ function getIcons( const parentReportAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; const actorAccountID = getReportActionActorAccountID(parentReportAction, report, report); - const actorDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(allPersonalDetails?.[actorAccountID ?? -1], '', false); + const actorDetails = actorAccountID ? personalDetails?.[actorAccountID] : undefined; + const actorDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(actorDetails, '', false); const actorIcon = { id: actorAccountID, - source: personalDetails?.[actorAccountID ?? -1]?.avatar ?? FallbackAvatar, + source: actorDetails?.avatar ?? FallbackAvatar, name: LocalePhoneNumber.formatPhoneNumber(actorDisplayName), type: CONST.ICON_TYPE_AVATAR, - fallbackIcon: personalDetails?.[parentReportAction?.actorAccountID ?? -1]?.fallbackIcon, + fallbackIcon: actorDetails?.fallbackIcon, }; if (isWorkspaceThread(report)) { @@ -2425,10 +2440,10 @@ function getIcons( if (isTaskReport(report)) { const ownerIcon = { id: report?.ownerAccountID, - source: personalDetails?.[report?.ownerAccountID ?? -1]?.avatar ?? FallbackAvatar, + source: ownerDetails?.avatar ?? FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, - name: personalDetails?.[report?.ownerAccountID ?? -1]?.displayName ?? '', - fallbackIcon: personalDetails?.[report?.ownerAccountID ?? -1]?.fallbackIcon, + name: ownerDetails?.displayName ?? '', + fallbackIcon: ownerDetails?.fallbackIcon, }; if (isWorkspaceTaskReport(report)) { @@ -2475,33 +2490,34 @@ function getIcons( if (isPolicyExpenseChat(report) || isExpenseReport(report)) { const workspaceIcon = getWorkspaceIcon(report, policy); const memberIcon = { - source: personalDetails?.[report?.ownerAccountID ?? -1]?.avatar ?? FallbackAvatar, + source: ownerDetails?.avatar ?? FallbackAvatar, id: report?.ownerAccountID, type: CONST.ICON_TYPE_AVATAR, - name: personalDetails?.[report?.ownerAccountID ?? -1]?.displayName ?? '', - fallbackIcon: personalDetails?.[report?.ownerAccountID ?? -1]?.fallbackIcon, + name: ownerDetails?.displayName ?? '', + fallbackIcon: ownerDetails?.fallbackIcon, }; return isExpenseReport(report) ? [memberIcon, workspaceIcon] : [workspaceIcon, memberIcon]; } if (isIOUReport(report)) { + const managerDetails = report?.managerID ? personalDetails?.[report.managerID] : undefined; const managerIcon = { - source: personalDetails?.[report?.managerID ?? -1]?.avatar ?? FallbackAvatar, + source: managerDetails?.avatar ?? FallbackAvatar, id: report?.managerID, type: CONST.ICON_TYPE_AVATAR, - name: personalDetails?.[report?.managerID ?? -1]?.displayName ?? '', - fallbackIcon: personalDetails?.[report?.managerID ?? -1]?.fallbackIcon, + name: managerDetails?.displayName ?? '', + fallbackIcon: managerDetails?.fallbackIcon, }; const ownerIcon = { id: report?.ownerAccountID, - source: personalDetails?.[report?.ownerAccountID ?? -1]?.avatar ?? FallbackAvatar, + source: ownerDetails?.avatar ?? FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, - name: personalDetails?.[report?.ownerAccountID ?? -1]?.displayName ?? '', - fallbackIcon: personalDetails?.[report?.ownerAccountID ?? -1]?.fallbackIcon, + name: ownerDetails?.displayName ?? '', + fallbackIcon: ownerDetails?.fallbackIcon, }; const isManager = currentUserAccountID === report?.managerID; // For one transaction IOUs, display a simplified report icon - if (isOneTransactionReport(report?.reportID ?? '-1')) { + if (isOneTransactionReport(report?.reportID)) { return [ownerIcon]; } @@ -2509,7 +2525,7 @@ function getIcons( } if (isSelfDM(report)) { - return getIconsForParticipants([currentUserAccountID ?? -1], personalDetails); + return getIconsForParticipants(currentUserAccountID ? [currentUserAccountID] : [], personalDetails); } if (isSystemChat(report)) { @@ -2767,7 +2783,7 @@ function buildOptimisticCancelPaymentReportAction(expenseReportID: string, amoun */ function getLastVisibleMessage(reportID: string | undefined, actionsToMerge: ReportActions = {}): LastVisibleMessage { const report = getReportOrDraftReport(reportID); - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID ?? '-1', canUserPerformWriteAction(report), actionsToMerge); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, canUserPerformWriteAction(report), actionsToMerge); // For Chat Report with deleted parent actions, let us fetch the correct message if (ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && !isEmptyObject(report) && isChatReport(report)) { @@ -2778,7 +2794,7 @@ function getLastVisibleMessage(reportID: string | undefined, actionsToMerge: Rep } // Fetch the last visible message for report represented by reportID and based on actions to merge. - return ReportActionsUtils.getLastVisibleMessage(reportID ?? '-1', canUserPerformWriteAction(report), actionsToMerge); + return ReportActionsUtils.getLastVisibleMessage(reportID, canUserPerformWriteAction(report), actionsToMerge); } /** @@ -2906,11 +2922,10 @@ function requiresAttentionFromCurrentUser(optionOrReport: OnyxEntry | Op } /** - * Returns number of transactions that are nonReimbursable - * + * Checks if the report contains at least one Non-Reimbursable transaction */ function hasNonReimbursableTransactions(iouReportID: string | undefined): boolean { - const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + const transactions = getReportTransactions(iouReportID); return transactions.filter((transaction) => transaction.reimbursable === false).length > 0; } @@ -2956,7 +2971,7 @@ function getMoneyRequestSpendBreakdown(report: OnyxInputOrEntry, allRepo */ function getPolicyExpenseChatName(report: OnyxEntry, policy?: OnyxEntry): string | undefined { const ownerAccountID = report?.ownerAccountID; - const personalDetails = allPersonalDetails?.[ownerAccountID ?? -1]; + const personalDetails = ownerAccountID ? allPersonalDetails?.[ownerAccountID] : undefined; const login = personalDetails ? personalDetails.login : null; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const reportOwnerDisplayName = getDisplayNameForParticipant(ownerAccountID) || login || report?.reportName; @@ -2975,7 +2990,7 @@ function getPolicyExpenseChatName(report: OnyxEntry, policy?: OnyxEntry< // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat // of the account which was merged into the current user's account. Use the name of the policy as the name of the report. if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) { - const lastAction = ReportActionsUtils.getLastVisibleAction(report?.reportID ?? '-1'); + const lastAction = ReportActionsUtils.getLastVisibleAction(report?.reportID); const archiveReason = ReportActionsUtils.isClosedAction(lastAction) ? ReportActionsUtils.getOriginalMessage(lastAction)?.reason : CONST.REPORT.ARCHIVE_REASON.DEFAULT; if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED && policyExpenseChatRole !== CONST.POLICY.ROLE.ADMIN) { return getPolicyName(report, false, policy); @@ -3006,7 +3021,7 @@ function isReportFieldOfTypeTitle(reportField: OnyxEntry): bo /** * Check if Report has any held expenses */ -function isHoldCreator(transaction: OnyxEntry, reportID: string): boolean { +function isHoldCreator(transaction: OnyxEntry, reportID: string | undefined): boolean { const holdReportAction = ReportActionsUtils.getReportAction(reportID, `${transaction?.comment?.hold ?? ''}`); return isActionCreator(holdReportAction); } @@ -3022,7 +3037,7 @@ function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry const isReportSettled = isSettled(report?.reportID); const isReportClosed = isClosedReport(report); const isTitleField = isReportFieldOfTypeTitle(reportField); - const isAdmin = isPolicyAdmin(report?.policyID ?? '-1', {[`${ONYXKEYS.COLLECTION.POLICY}${policy?.id ?? '-1'}`]: policy}); + const isAdmin = isPolicyAdmin(report?.policyID, {[`${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`]: policy}); return isTitleField ? !reportField?.deletable : !isAdmin && (isReportSettled || isReportClosed); } @@ -3053,7 +3068,7 @@ function getReportFieldKey(reportFieldId: string | undefined) { /** * Get the report fields attached to the policy given policyID */ -function getReportFieldsByPolicyID(policyID: string): Record { +function getReportFieldsByPolicyID(policyID: string | undefined): Record { const policyReportFields = Object.entries(allPolicies ?? {}).find(([key]) => key.replace(ONYXKEYS.COLLECTION.POLICY, '') === policyID); const fieldList = policyReportFields?.[1]?.fieldList; @@ -3105,8 +3120,8 @@ function getAvailableReportFields(report: Report, policyReportFields: PolicyRepo * Get the title for an IOU or expense chat which will be showing the payer and the amount */ function getMoneyRequestReportName(report: OnyxEntry, policy?: OnyxEntry, invoiceReceiverPolicy?: OnyxEntry): string { - const isReportSettled = isSettled(report?.reportID ?? '-1'); - const reportFields = isReportSettled ? report?.fieldList : getReportFieldsByPolicyID(report?.policyID ?? '-1'); + const isReportSettled = isSettled(report?.reportID); + const reportFields = isReportSettled ? report?.fieldList : getReportFieldsByPolicyID(report?.policyID); const titleReportField = Object.values(reportFields ?? {}).find((reportField) => reportField?.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID); if (titleReportField && report?.reportName && isPaidGroupPolicyExpenseReport(report)) { @@ -3229,7 +3244,7 @@ function canEditMoneyRequest(reportAction: OnyxInputOrEntry) return {canHoldRequest: false, canUnholdRequest: false}; } - const moneyRequestReportID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUReportID ?? 0; + const moneyRequestReportID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUReportID; const moneyRequestReport = getReportOrDraftReport(String(moneyRequestReportID)); if (!moneyRequestReportID || !moneyRequestReport) { @@ -3375,7 +3390,7 @@ function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry) const isRequestSettled = isSettled(moneyRequestReport?.reportID); const isApproved = isReportApproved(moneyRequestReport); - const transactionID = moneyRequestReport ? ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID : 0; + const transactionID = moneyRequestReport ? ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID : undefined; const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); const parentReportAction = isThread(moneyRequestReport) @@ -3383,7 +3398,7 @@ function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry) : undefined; const isRequestIOU = isIOUReport(moneyRequestReport); - const isHoldActionCreator = isHoldCreator(transaction, reportAction.childReportID ?? '-1'); + const isHoldActionCreator = isHoldCreator(transaction, reportAction.childReportID); const isTrackExpenseMoneyReport = isTrackExpenseReport(moneyRequestReport); const isActionOwner = @@ -3391,7 +3406,7 @@ function canHoldUnholdReportAction(reportAction: OnyxInputOrEntry) typeof currentUserPersonalDetails?.accountID === 'number' && parentReportAction.actorAccountID === currentUserPersonalDetails?.accountID; const isApprover = isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && currentUserPersonalDetails?.accountID === moneyRequestReport?.managerID; - const isAdmin = isPolicyAdmin(moneyRequestReport.policyID ?? '-1', allPolicies); + const isAdmin = isPolicyAdmin(moneyRequestReport.policyID, allPolicies); const isOnHold = TransactionUtils.isOnHold(transaction); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isClosed = isClosedReport(moneyRequestReport); @@ -3413,25 +3428,31 @@ const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry, bac if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) { return; } - const moneyRequestReportID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUReportID ?? 0; + const moneyRequestReportID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUReportID; const moneyRequestReport = getReportOrDraftReport(String(moneyRequestReportID)); if (!moneyRequestReportID || !moneyRequestReport) { return; } - const transactionID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID ?? ''; + const transactionID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID; + + if (!transactionID || !reportAction.childReportID) { + Log.warn('Missing transactionID and reportAction.childReportID during the change of the money request hold status'); + return; + } + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); const isOnHold = TransactionUtils.isOnHold(transaction); const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport.policyID}`] ?? null; if (isOnHold) { - IOU.unholdRequest(transactionID, reportAction.childReportID ?? '', searchHash); + IOU.unholdRequest(transactionID, reportAction.childReportID, searchHash); } else { const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); Navigation.navigate( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, transactionID, reportAction.childReportID ?? '', backTo || activeRoute, searchHash), + ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, transactionID, reportAction.childReportID, backTo || activeRoute, searchHash), ); } }; @@ -3440,7 +3461,7 @@ const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry, bac * Gets all transactions on an IOU report with a receipt */ function getTransactionsWithReceipts(iouReportID: string | undefined): Transaction[] { - const transactions = reportsTransactions[iouReportID ?? ''] ?? []; + const transactions = getReportTransactions(iouReportID); return transactions.filter((transaction) => TransactionUtils.hasReceipt(transaction)); } @@ -3467,10 +3488,10 @@ function areAllRequestsBeingSmartScanned(iouReportID: string, reportPreviewActio * NOTE: This method is only meant to be used inside this action file. Do not export and use it elsewhere. Use withOnyx or Onyx.connect() instead. */ function getLinkedTransaction(reportAction: OnyxEntry): OnyxEntry { - let transactionID = ''; + let transactionID; if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { - transactionID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID ?? '-1'; + transactionID = ReportActionsUtils.getOriginalMessage(reportAction)?.IOUTransactionID; } return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; @@ -3480,7 +3501,7 @@ function getLinkedTransaction(reportAction: OnyxEntry { if (!ReportActionsUtils.isMoneyRequestAction(action)) { @@ -3508,7 +3529,7 @@ function getReportActionWithMissingSmartscanFields(iouReportID: string): ReportA /** * Check if iouReportID has required missing fields */ -function shouldShowRBRForMissingSmartscanFields(iouReportID: string): boolean { +function shouldShowRBRForMissingSmartscanFields(iouReportID: string | undefined): boolean { return !!getReportActionWithMissingSmartscanFields(iouReportID); } @@ -3683,7 +3704,7 @@ function getReportPreviewMessage( } if (report.isWaitingOnBankAccount) { - const submitterDisplayName = getDisplayNameForParticipant(report.ownerAccountID ?? -1, true) ?? ''; + const submitterDisplayName = getDisplayNameForParticipant(report.ownerAccountID, true) ?? ''; return Localize.translateLocal('iou.waitingOnBankAccount', {submitterDisplayName}); } @@ -3832,15 +3853,15 @@ function getAdminRoomInvitedParticipants(parentReportAction: OnyxEntry { - const name = getDisplayNameForParticipant(id); + const participants = personalDetails.map((personalDetail) => { + const name = PersonalDetailsUtils.getEffectiveDisplayName(personalDetail); if (name && name?.length > 0) { return name; } @@ -3883,7 +3904,7 @@ function getInvoicePayerName(report: OnyxEntry, invoiceReceiverPolicy?: /** * Parse html of reportAction into text */ -function parseReportActionHtmlToText(reportAction: OnyxEntry, reportID: string, childReportID?: string): string { +function parseReportActionHtmlToText(reportAction: OnyxEntry, reportID: string | undefined, childReportID?: string): string { if (!reportAction) { return ''; } @@ -3947,7 +3968,7 @@ function getReportActionMessage(reportAction: OnyxEntry, reportID? return getReimbursementQueuedActionMessage(reportAction, getReportOrDraftReport(reportID), false); } - return parseReportActionHtmlToText(reportAction, reportID ?? '', childReportID); + return parseReportActionHtmlToText(reportAction, reportID, childReportID); } /** @@ -3956,8 +3977,8 @@ function getReportActionMessage(reportAction: OnyxEntry, reportID? function getInvoicesChatName(report: OnyxEntry, receiverPolicy: OnyxEntry): string { const invoiceReceiver = report?.invoiceReceiver; const isIndividual = invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL; - const invoiceReceiverAccountID = isIndividual ? invoiceReceiver.accountID : -1; - const invoiceReceiverPolicyID = isIndividual ? '' : invoiceReceiver?.policyID ?? '-1'; + const invoiceReceiverAccountID = isIndividual ? invoiceReceiver.accountID : CONST.DEFAULT_NUMBER_ID; + const invoiceReceiverPolicyID = isIndividual ? undefined : invoiceReceiver?.policyID; const invoiceReceiverPolicy = receiverPolicy ?? getPolicy(invoiceReceiverPolicyID); const isCurrentUserReceiver = (isIndividual && invoiceReceiverAccountID === currentUserAccountID) || (!isIndividual && PolicyUtils.isPolicyAdmin(invoiceReceiverPolicy)); @@ -4058,7 +4079,7 @@ function getReportName( } const isAttachment = ReportActionsUtils.isReportActionAttachment(!isEmptyObject(parentReportAction) ? parentReportAction : undefined); - const reportActionMessage = getReportActionMessage(parentReportAction, report?.parentReportID, report?.reportID ?? '').replace(/(\n+|\r\n|\n|\r)/gm, ' '); + const reportActionMessage = getReportActionMessage(parentReportAction, report?.parentReportID, report?.reportID).replace(/(\n+|\r\n|\n|\r)/gm, ' '); if (isAttachment && reportActionMessage) { return `[${Localize.translateLocal('common.attachment')}]`; } @@ -4271,7 +4292,11 @@ function goBackToDetailsPage(report: OnyxEntry, backTo?: string) { return; } - Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report?.reportID ?? '-1', backTo)); + if (report?.reportID) { + Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report.reportID, backTo)); + } else { + Log.warn('Missing reportID during navigation back to the details page'); + } } function navigateBackOnDeleteTransaction(backRoute: Route | undefined, isFromRHP?: boolean) { @@ -4458,7 +4483,7 @@ function buildOptimisticAddCommentReportAction( const isAttachmentOnly = file && !text; const isAttachmentWithText = !!text && file !== undefined; - const accountID = actorAccountID ?? currentUserAccountID ?? -1; + const accountID = actorAccountID ?? currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID; const delegateAccountDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail); // Remove HTML from text when applying optimistic offline comment @@ -4594,6 +4619,31 @@ function buildOptimisticTaskCommentReportAction( return reportAction; } +function buildOptimisticSelfDMReport(created: string): Report { + return { + reportID: generateReportID(), + participants: { + [currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE, + }, + }, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + isOwnPolicyExpenseChat: false, + isPinned: true, + lastActorAccountID: 0, + lastMessageHtml: '', + lastMessageText: undefined, + lastReadTime: created, + lastVisibleActionCreated: created, + ownerAccountID: currentUserAccountID, + reportName: '', + stateNum: 0, + statusNum: 0, + writeCapability: CONST.REPORT.WRITE_CAPABILITIES.ALL, + }; +} + /** * Builds an optimistic IOU report with a randomly generated reportID * @@ -4618,7 +4668,7 @@ function buildOptimisticIOUReport( const formattedTotal = CurrencyUtils.convertToDisplayString(total, currency); const personalDetails = getPersonalDetailsForAccountID(payerAccountID); const payerEmail = 'login' in personalDetails ? personalDetails.login : ''; - const policyID = getReport(chatReportID)?.policyID ?? '-1'; + const policyID = getReport(chatReportID)?.policyID; const policy = getPolicy(policyID); const participants: Participants = { @@ -4684,8 +4734,7 @@ function populateOptimisticReportFormula(formula: string, report: OptimisticExpe /** Builds an optimistic invoice report with a randomly generated reportID */ function buildOptimisticInvoiceReport(chatReportID: string, policyID: string, receiverAccountID: number, receiverName: string, total: number, currency: string): OptimisticExpenseReport { const formattedTotal = CurrencyUtils.convertToDisplayString(total, currency); - - return { + const invoiceReport = { reportID: generateReportID(), chatReportID, policyID, @@ -4699,9 +4748,6 @@ function buildOptimisticInvoiceReport(chatReportID: string, policyID: string, re statusNum: CONST.REPORT.STATUS_NUM.OPEN, total, participants: { - [currentUserAccountID ?? -1]: { - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, - }, [receiverAccountID]: { notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, }, @@ -4709,6 +4755,12 @@ function buildOptimisticInvoiceReport(chatReportID: string, policyID: string, re parentReportID: chatReportID, lastVisibleActionCreated: DateUtils.getDBTime(), }; + + if (currentUserAccountID) { + invoiceReport.participants[currentUserAccountID] = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}; + } + + return invoiceReport; } /** @@ -5035,8 +5087,8 @@ function buildOptimisticIOUReportAction( originalMessage.participantAccountIDs = currentUserAccountID ? [currentUserAccountID] : []; } else { originalMessage.participantAccountIDs = currentUserAccountID - ? [currentUserAccountID, ...participants.map((participant) => participant.accountID ?? -1)] - : participants.map((participant) => participant.accountID ?? -1); + ? [currentUserAccountID, ...participants.map((participant) => participant.accountID ?? CONST.DEFAULT_NUMBER_ID)] + : participants.map((participant) => participant.accountID ?? CONST.DEFAULT_NUMBER_ID); } } @@ -5247,14 +5299,14 @@ function buildOptimisticReportPreview( }, ], created, - accountID: iouReport?.managerID ?? -1, + accountID: iouReport?.managerID, // The preview is initially whispered if created with a receipt, so the actor is the current user as well actorAccountID: hasReceipt ? currentUserAccountID : reportActorAccountID, childReportID: childReportID ?? iouReport?.reportID, childMoneyRequestCount: 1, childLastActorAccountID: currentUserAccountID, childLastMoneyRequestComment: comment, - childRecentReceiptTransactionIDs: hasReceipt && !isEmptyObject(transaction) ? {[transaction?.transactionID ?? '-1']: created} : undefined, + childRecentReceiptTransactionIDs: hasReceipt && !isEmptyObject(transaction) && transaction?.transactionID ? {[transaction.transactionID]: created} : undefined, }; } @@ -5432,11 +5484,13 @@ function updateReportPreview( : recentReceiptTransactions, // As soon as we add a transaction without a receipt to the report, it will have ready expenses, // so we remove the whisper - originalMessage: { - ...(originalMessage ?? {}), - whisperedTo: hasReceipt ? originalMessage?.whisperedTo : [], - linkedReportID: originalMessage?.linkedReportID ?? '0', - }, + originalMessage: originalMessage + ? { + ...originalMessage, + whisperedTo: hasReceipt ? originalMessage.whisperedTo : [], + linkedReportID: originalMessage.linkedReportID, + } + : undefined, }; } @@ -6086,7 +6140,7 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp const pendingChatMembers = getPendingChatMembers(currentUserAccountID ? [currentUserAccountID] : [], [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); const adminsChatData = { ...buildOptimisticChatReport( - [currentUserAccountID ?? -1], + currentUserAccountID ? [currentUserAccountID] : [], CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policyID, @@ -6102,7 +6156,7 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp }; const expenseChatData = buildOptimisticChatReport( - [currentUserAccountID ?? -1], + currentUserAccountID ? [currentUserAccountID] : [], '', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, @@ -6246,7 +6300,7 @@ function buildTransactionThread( participantAccountIDs, getTransactionReportName(reportAction), undefined, - moneyRequestReport?.policyID ?? '-1', + moneyRequestReport?.policyID, CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, false, '', @@ -6409,7 +6463,7 @@ function isReportNotFound(report: OnyxEntry): boolean { /** * Check if the report is the parent report of the currently viewed report or at least one child report has report action */ -function shouldHideReport(report: OnyxEntry, currentReportId: string): boolean { +function shouldHideReport(report: OnyxEntry, currentReportId: string | undefined): boolean { const currentReport = getReportOrDraftReport(currentReportId); const parentReport = getParentReport(!isEmptyObject(currentReport) ? currentReport : undefined); const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? {}; @@ -6427,7 +6481,7 @@ function shouldDisplayViolationsRBRInLHN(report: OnyxEntry, transactionV } // We only show the RBR to the submitter - if (!isCurrentUserSubmitter(report.reportID ?? '')) { + if (!isCurrentUserSubmitter(report.reportID)) { return false; } @@ -6447,7 +6501,7 @@ function shouldDisplayViolationsRBRInLHN(report: OnyxEntry, transactionV * Checks to see if a report contains a violation */ function hasViolations(reportID: string, transactionViolations: OnyxCollection, shouldShowInReview?: boolean): boolean { - const transactions = reportsTransactions[reportID] ?? []; + const transactions = getReportTransactions(reportID); return transactions.some((transaction) => TransactionUtils.hasViolation(transaction.transactionID, transactionViolations, shouldShowInReview)); } @@ -6455,7 +6509,7 @@ function hasViolations(reportID: string, transactionViolations: OnyxCollection, shouldShowInReview?: boolean): boolean { - const transactions = reportsTransactions[reportID] ?? []; + const transactions = getReportTransactions(reportID); return transactions.some((transaction) => TransactionUtils.hasWarningTypeViolation(transaction.transactionID, transactionViolations, shouldShowInReview)); } @@ -6463,7 +6517,7 @@ function hasWarningTypeViolations(reportID: string, transactionViolations: OnyxC * Checks to see if a report contains a violation of type `notice` */ function hasNoticeTypeViolations(reportID: string, transactionViolations: OnyxCollection, shouldShowInReview?: boolean): boolean { - const transactions = reportsTransactions[reportID] ?? []; + const transactions = getReportTransactions(reportID); return transactions.some((transaction) => TransactionUtils.hasNoticeTypeViolation(transaction.transactionID, transactionViolations, shouldShowInReview)); } @@ -6479,7 +6533,7 @@ function hasReportViolations(reportID: string) { function shouldAdminsRoomBeVisible(report: OnyxEntry): boolean { const accountIDs = Object.entries(report?.participants ?? {}).map(([accountID]) => Number(accountID)); const adminAccounts = PersonalDetailsUtils.getLoginsByAccountIDs(accountIDs).filter((login) => !PolicyUtils.isExpensifyTeam(login)); - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(report?.reportID ?? ''); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(report?.reportID); if ((lastVisibleAction ? ReportActionsUtils.isCreatedAction(lastVisibleAction) : report?.lastActionType === CONST.REPORT.ACTIONS.TYPE.CREATED) && adminAccounts.length <= 1) { return false; } @@ -6508,7 +6562,7 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention(report: O const parentReportAction: OnyxEntry = !report?.parentReportID || !report?.parentReportActionID ? undefined - : allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID ?? '-1'}`]?.[report.parentReportActionID ?? '-1']; + : allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`]?.[report.parentReportActionID]; if (!isArchivedRoom(report)) { if (ReportActionsUtils.wasActionTakenByCurrentUser(parentReportAction) && ReportActionsUtils.isTransactionThread(parentReportAction)) { @@ -6519,9 +6573,9 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention(report: O reportAction = undefined; } } else if ((isIOUReport(report) || isExpenseReport(report)) && report?.ownerAccountID === currentUserAccountID) { - if (shouldShowRBRForMissingSmartscanFields(report?.reportID ?? '-1') && !isSettled(report?.reportID)) { + if (shouldShowRBRForMissingSmartscanFields(report?.reportID) && !isSettled(report?.reportID)) { reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage'); - reportAction = getReportActionWithMissingSmartscanFields(report?.reportID ?? '-1'); + reportAction = getReportActionWithMissingSmartscanFields(report?.reportID); } } else if (hasSmartscanError(reportActionsArray)) { reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericSmartscanFailureMessage'); @@ -6579,7 +6633,7 @@ function hasReportErrorsOtherThanFailedReceipt(report: Report, doesReportHaveVio type ShouldReportBeInOptionListParams = { report: OnyxEntry; - currentReportId: string; + currentReportId: string | undefined; isInFocusMode: boolean; betas: OnyxEntry; policies: OnyxCollection; @@ -6641,7 +6695,7 @@ function reasonForReportToBeInOptionList({ } // If this is a transaction thread associated with a report that only has one transaction, omit it - if (isOneTransactionThread(report.reportID, report.parentReportID ?? '-1', parentReportAction)) { + if (isOneTransactionThread(report.reportID, report.parentReportID, parentReportAction)) { return null; } @@ -6724,11 +6778,7 @@ function reasonForReportToBeInOptionList({ } // Hide chat threads where the parent message is pending removal - if ( - !isEmptyObject(parentReportAction) && - ReportActionsUtils.isPendingRemove(parentReportAction) && - ReportActionsUtils.isThreadParentMessage(parentReportAction, report?.reportID ?? '') - ) { + if (!isEmptyObject(parentReportAction) && ReportActionsUtils.isPendingRemove(parentReportAction) && ReportActionsUtils.isThreadParentMessage(parentReportAction, report?.reportID)) { return null; } @@ -6793,10 +6843,11 @@ function getInvoiceChatByParticipants(receiverID: string | number, receiverType: /** * Attempts to find a policy expense report in onyx that is owned by ownerAccountID in a given policy */ -function getPolicyExpenseChat(ownerAccountID: number, policyID: string | undefined): OnyxEntry { - if (!policyID) { +function getPolicyExpenseChat(ownerAccountID: number | undefined, policyID: string | undefined): OnyxEntry { + if (!ownerAccountID || !policyID) { return; } + return Object.values(allReports ?? {}).find((report: OnyxEntry) => { // If the report has been deleted, then skip it if (!report) { @@ -6878,21 +6929,6 @@ function shouldShowFlagComment(reportAction: OnyxInputOrEntry, rep ); } -/** - * @param sortedAndFilteredReportActions - reportActions for the report, sorted newest to oldest, and filtered for only those that should be visible - */ -function getNewMarkerReportActionID(report: OnyxEntry, sortedAndFilteredReportActions: ReportAction[]): string { - if (!isUnread(report)) { - return ''; - } - - const newMarkerIndex = lodashFindLastIndex(sortedAndFilteredReportActions, (reportAction) => (reportAction.created ?? '') > (report?.lastReadTime ?? '')); - - return newMarkerIndex !== -1 && 'reportActionID' in (sortedAndFilteredReportActions?.at(newMarkerIndex) ?? {}) - ? sortedAndFilteredReportActions.at(newMarkerIndex)?.reportActionID ?? '' - : ''; -} - /** * Performs the markdown conversion, and replaces code points > 127 with C escape sequences * Used for compatibility with the backend auth validator for AddComment, and to account for MD in comments @@ -7076,7 +7112,7 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, policy: OnyxEntry 0; - const isPolicyOwnedByExpensifyAccounts = report?.policyID ? CONST.EXPENSIFY_ACCOUNT_IDS.includes(getPolicy(report?.policyID ?? '-1')?.ownerAccountID ?? -1) : false; + const policyOwnerAccountID = getPolicy(report?.policyID)?.ownerAccountID; + const isPolicyOwnedByExpensifyAccounts = policyOwnerAccountID ? CONST.EXPENSIFY_ACCOUNT_IDS.includes(policyOwnerAccountID) : false; if (doParticipantsIncludeExpensifyAccounts && !isPolicyOwnedByExpensifyAccounts) { return []; } @@ -7198,7 +7235,7 @@ function canLeaveRoom(report: OnyxEntry, isPolicyEmployee: boolean): boo return false; } - const invoiceReport = getReportOrDraftReport(report?.iouReportID ?? '-1'); + const invoiceReport = getReportOrDraftReport(report?.iouReportID); if (invoiceReport?.ownerAccountID === currentUserAccountID) { return false; @@ -7281,7 +7318,7 @@ function shouldReportShowSubscript(report: OnyxEntry): boolean { return true; } - if (isExpenseReport(report) && isOneTransactionReport(report?.reportID ?? '-1')) { + if (isExpenseReport(report) && isOneTransactionReport(report?.reportID)) { return true; } @@ -7332,7 +7369,7 @@ function isMoneyRequestReportPendingDeletion(reportOrID: OnyxEntry | str return false; } - const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); + const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID, report?.parentReportActionID); return parentReportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } @@ -7353,7 +7390,7 @@ function canUserPerformWriteAction(report: OnyxEntry) { */ function getOriginalReportID(reportID: string, reportAction: OnyxInputOrEntry): string | undefined { const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]; - const currentReportAction = reportActions?.[reportAction?.reportActionID ?? '-1'] ?? null; + const currentReportAction = reportAction?.reportActionID ? reportActions?.[reportAction.reportActionID] : undefined; const transactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(reportID, reportActions ?? ([] as ReportAction[])); const isThreadReportParentAction = reportAction?.childReportID?.toString() === reportID; if (Object.keys(currentReportAction ?? {}).length === 0) { @@ -7398,7 +7435,9 @@ function canCreateRequest(report: OnyxEntry, policy: OnyxEntry, } function getWorkspaceChats(policyID: string, accountIDs: number[], reports: OnyxCollection = allReports): Array> { - return Object.values(reports ?? {}).filter((report) => isPolicyExpenseChat(report) && (report?.policyID ?? '-1') === policyID && accountIDs.includes(report?.ownerAccountID ?? -1)); + return Object.values(reports ?? {}).filter( + (report) => isPolicyExpenseChat(report) && report?.policyID === policyID && report?.ownerAccountID && accountIDs.includes(report?.ownerAccountID), + ); } /** @@ -7407,7 +7446,7 @@ function getWorkspaceChats(policyID: string, accountIDs: number[], reports: Onyx * @param policyID - the workspace ID to get all associated reports */ function getAllWorkspaceReports(policyID: string): Array> { - return Object.values(allReports ?? {}).filter((report) => (report?.policyID ?? '-1') === policyID); + return Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID); } /** @@ -7557,7 +7596,7 @@ function getTaskAssigneeChatOnyxData( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, - value: {[optimisticAssigneeAddComment.reportAction.reportActionID ?? '-1']: optimisticAssigneeAddComment.reportAction as ReportAction}, + value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: optimisticAssigneeAddComment.reportAction as ReportAction}, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -7568,12 +7607,12 @@ function getTaskAssigneeChatOnyxData( successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, - value: {[optimisticAssigneeAddComment.reportAction.reportActionID ?? '-1']: {isOptimisticAction: null}}, + value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: {isOptimisticAction: null}}, }); failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, - value: {[optimisticAssigneeAddComment.reportAction.reportActionID ?? '-1']: {pendingAction: null}}, + value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: {pendingAction: null}}, }); } @@ -7606,7 +7645,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry, switch (originalMessage.paymentType) { case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: - translationKey = hasMissingInvoiceBankAccount(IOUReportID ?? '-1') ? 'iou.payerSettledWithMissingBankAccount' : 'iou.paidElsewhereWithAmount'; + translationKey = hasMissingInvoiceBankAccount(IOUReportID) ? 'iou.payerSettledWithMissingBankAccount' : 'iou.paidElsewhereWithAmount'; break; case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: case CONST.IOU.PAYMENT_TYPE.VBBA: @@ -7693,7 +7732,7 @@ function isValidReport(report?: OnyxEntry): boolean { /** * Check to see if we are a participant of this report. */ -function isReportParticipant(accountID: number, report: OnyxEntry): boolean { +function isReportParticipant(accountID: number | undefined, report: OnyxEntry): boolean { if (!accountID) { return false; } @@ -7712,7 +7751,7 @@ function isReportParticipant(accountID: number, report: OnyxEntry): bool * Check to see if the current user has access to view the report. */ function canCurrentUserOpenReport(report: OnyxEntry): boolean { - return (isReportParticipant(currentUserAccountID ?? 0, report) || isPublicRoom(report)) && canAccessReport(report, allPolicies, allBetas); + return (isReportParticipant(currentUserAccountID, report) || isPublicRoom(report)) && canAccessReport(report, allPolicies, allBetas); } function shouldUseFullTitleToDisplay(report: OnyxEntry): boolean { @@ -7736,7 +7775,7 @@ function canEditReportDescription(report: OnyxEntry, policy: OnyxEntry

, session: OnyxEntry TransactionUtils.isOnHold(transaction)); } @@ -7817,7 +7856,8 @@ function getAllHeldTransactions(iouReportID?: string): Transaction[] { * Check if Report has any held expenses */ function hasHeldExpenses(iouReportID?: string, allReportTransactions?: SearchTransaction[]): boolean { - const transactions = allReportTransactions ?? reportsTransactions[iouReportID ?? ''] ?? []; + const iouReportTransactions = getReportTransactions(iouReportID); + const transactions = allReportTransactions ?? iouReportTransactions; return transactions.some((transaction) => TransactionUtils.isOnHold(transaction)); } @@ -7825,7 +7865,8 @@ function hasHeldExpenses(iouReportID?: string, allReportTransactions?: SearchTra * Check if all expenses in the Report are on hold */ function hasOnlyHeldExpenses(iouReportID?: string, allReportTransactions?: SearchTransaction[]): boolean { - const reportTransactions = allReportTransactions ?? reportsTransactions[iouReportID ?? ''] ?? []; + const transactionsByIouReportID = getReportTransactions(iouReportID); + const reportTransactions = allReportTransactions ?? transactionsByIouReportID; return reportTransactions.length > 0 && !reportTransactions.some((transaction) => !TransactionUtils.isOnHold(transaction)); } @@ -7845,7 +7886,7 @@ function hasUpdatedTotal(report: OnyxInputOrEntry, policy: OnyxInputOrEn return true; } - const allReportTransactions = reportsTransactions[report.reportID] ?? []; + const allReportTransactions = getReportTransactions(report.reportID); const hasPendingTransaction = allReportTransactions.some((transaction) => !!transaction.pendingAction); const hasTransactionWithDifferentCurrency = allReportTransactions.some((transaction) => transaction.currency !== report.currency); @@ -7915,7 +7956,7 @@ function getAllAncestorReportActions(report: Report | null | undefined, currentU while (parentReportID) { const parentReport = currentUpdatedReport && currentUpdatedReport.reportID === parentReportID ? currentUpdatedReport : getReportOrDraftReport(parentReportID); - const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '-1'); + const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID); if ( !parentReport || @@ -7957,7 +7998,7 @@ function getAllAncestorReportActionIDs(report: Report | null | undefined, includ while (parentReportID) { const parentReport = getReportOrDraftReport(parentReportID); - const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '-1'); + const parentReportAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID); if ( !parentReportAction || @@ -7968,8 +8009,10 @@ function getAllAncestorReportActionIDs(report: Report | null | undefined, includ break; } - allAncestorIDs.reportIDs.push(parentReportID ?? '-1'); - allAncestorIDs.reportActionsIDs.push(parentReportActionID ?? '-1'); + allAncestorIDs.reportIDs.push(parentReportID); + if (parentReportActionID) { + allAncestorIDs.reportActionsIDs.push(parentReportActionID); + } if (!parentReport) { break; @@ -8007,7 +8050,7 @@ function getOptimisticDataForParentReportAction(reportID: string, lastVisibleAct const ancestorReportAction = ReportActionsUtils.getReportAction(ancestorReport.reportID, ancestors.reportActionsIDs.at(index) ?? ''); - if (!ancestorReportAction || isEmptyObject(ancestorReportAction)) { + if (!ancestorReportAction?.reportActionID || isEmptyObject(ancestorReportAction)) { return null; } @@ -8015,7 +8058,7 @@ function getOptimisticDataForParentReportAction(reportID: string, lastVisibleAct onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`, value: { - [ancestorReportAction?.reportActionID ?? '-1']: updateOptimisticParentReportAction(ancestorReportAction, lastVisibleActionCreated, type), + [ancestorReportAction.reportActionID]: updateOptimisticParentReportAction(ancestorReportAction, lastVisibleActionCreated, type), }, }; }); @@ -8120,7 +8163,7 @@ function getTripTransactions(tripRoomReportID: string | undefined, reportFieldTo const tripTransactionReportIDs = Object.values(allReports ?? {}) .filter((report) => report && report?.[reportFieldToCompare] === tripRoomReportID) .map((report) => report?.reportID); - return tripTransactionReportIDs.flatMap((reportID) => reportsTransactions[reportID ?? ''] ?? []); + return tripTransactionReportIDs.flatMap((reportID) => getReportTransactions(reportID)); } function getTripIDFromTransactionParentReportID(transactionParentReportID: string | undefined): string | undefined { @@ -8138,13 +8181,13 @@ function hasActionsWithErrors(reportID: string): boolean { } function isNonAdminOrOwnerOfPolicyExpenseChat(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { - return isPolicyExpenseChat(report) && !(PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID ?? -1) || isReportOwner(report)); + return isPolicyExpenseChat(report) && !(PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID) || isReportOwner(report)); } function isAdminOwnerApproverOrReportOwner(report: OnyxEntry, policy: OnyxEntry): boolean { const isApprover = isMoneyRequestReport(report) && report?.managerID !== null && currentUserPersonalDetails?.accountID === report?.managerID; - return PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID ?? -1) || isReportOwner(report) || isApprover; + return PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPolicyOwner(policy, currentUserAccountID) || isReportOwner(report) || isApprover; } /** @@ -8300,18 +8343,22 @@ function createDraftTransactionAndNavigateToParticipantSelector( if (actionName === CONST.IOU.ACTION.CATEGORIZE) { const activePolicy = getPolicy(activePolicyID); if (activePolicy && activePolicy?.type !== CONST.POLICY.TYPE.PERSONAL && activePolicy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - const policyExpenseReportID = getPolicyExpenseChat(currentUserAccountID ?? -1, activePolicyID ?? '-1')?.reportID ?? '-1'; + const policyExpenseReportID = getPolicyExpenseChat(currentUserAccountID, activePolicyID)?.reportID; IOU.setMoneyRequestParticipants(transactionID, [ { selected: true, accountID: 0, isPolicyExpenseChat: true, reportID: policyExpenseReportID, - policyID: activePolicyID ?? '-1', + policyID: activePolicyID, searchText: activePolicy?.name, }, ]); - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, policyExpenseReportID)); + if (policyExpenseReportID) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, policyExpenseReportID)); + } else { + Log.warn('policyExpenseReportID is not valid during expense categorizing'); + } return; } if (filteredPolicies.length === 0 || filteredPolicies.length > 1) { @@ -8320,18 +8367,22 @@ function createDraftTransactionAndNavigateToParticipantSelector( } const policyID = filteredPolicies.at(0)?.id; - const policyExpenseReportID = getPolicyExpenseChat(currentUserAccountID ?? -1, policyID ?? '-1')?.reportID ?? '-1'; + const policyExpenseReportID = getPolicyExpenseChat(currentUserAccountID, policyID)?.reportID; IOU.setMoneyRequestParticipants(transactionID, [ { selected: true, accountID: 0, isPolicyExpenseChat: true, reportID: policyExpenseReportID, - policyID: policyID ?? '-1', + policyID, searchText: activePolicy?.name, }, ]); - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, policyExpenseReportID)); + if (policyExpenseReportID) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, policyExpenseReportID)); + } else { + Log.warn('policyExpenseReportID is not valid during expense categorizing'); + } return; } @@ -8520,7 +8571,7 @@ function getApprovalChain(policy: OnyxEntry, expenseReport: OnyxEntry, expenseReport: OnyxEntry isInvoiceReport(report)); } -function getReportMetadata(reportID?: string) { - return allReportMetadataKeyValue[reportID ?? '-1']; +function getReportMetadata(reportID: string | undefined) { + return reportID ? allReportMetadataKeyValue[reportID] : undefined; } export { @@ -8687,7 +8738,6 @@ export { getLastVisibleMessage, getMoneyRequestOptions, getMoneyRequestSpendBreakdown, - getNewMarkerReportActionID, getNonHeldAndFullAmount, getOptimisticDataForParentReportAction, getOriginalReportID, @@ -8901,6 +8951,7 @@ export { getAllReportActionsErrorsAndReportActionThatRequiresAttention, hasInvoiceReports, getReportMetadata, + buildOptimisticSelfDMReport, isHiddenForCurrentUser, }; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 849008e10b76..b48846f95a0a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -264,7 +264,7 @@ function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTr // We need to check both options for a falsy value since the transaction might not have an error but the report associated with it might // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (transaction?.hasError || report.hasError) { + if (transaction?.errors || report?.errors) { return CONST.SEARCH.ACTION_TYPES.REVIEW; } @@ -338,6 +338,10 @@ function getReportActionsSections(data: OnyxTypes.SearchResults['data']): Report // eslint-disable-next-line no-continue continue; } + if (!ReportActionsUtils.isAddCommentAction(reportAction)) { + // eslint-disable-next-line no-continue + continue; + } reportActionItems.push({ ...reportAction, from, diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 1aa5f5fe101e..528481dae237 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -131,6 +131,18 @@ function isScanRequest(transaction: OnyxEntry): boolean { return !!transaction?.receipt?.source && transaction?.amount === 0; } +function isPerDiemRequest(transaction: OnyxEntry): boolean { + // This is used during the expense creation flow before the transaction has been saved to the server + if (lodashHas(transaction, 'iouRequestType')) { + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.PER_DIEM; + } + + // This is the case for transaction objects once they have been saved to the server + const type = transaction?.comment?.type; + const customUnitName = transaction?.comment?.customUnit?.name; + return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL; +} + function getRequestType(transaction: OnyxEntry): IOURequestType { if (isDistanceRequest(transaction)) { return CONST.IOU.REQUEST_TYPE.DISTANCE; @@ -139,6 +151,10 @@ function getRequestType(transaction: OnyxEntry): IOURequestType { return CONST.IOU.REQUEST_TYPE.SCAN; } + if (isPerDiemRequest(transaction)) { + return CONST.IOU.REQUEST_TYPE.PER_DIEM; + } + return CONST.IOU.REQUEST_TYPE.MANUAL; } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 40e2b188d33f..09a1f0b4f8fd 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -11,7 +11,7 @@ import type { ValidateBankAccountWithTransactionsParams, VerifyIdentityForBankAccountParams, } from '@libs/API/parameters'; -import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as Localize from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; @@ -19,7 +19,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; -import type {InternationalBankAccountForm, PersonalBankAccountForm} from '@src/types/form'; +import type {PersonalBankAccountForm} from '@src/types/form'; import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, ReimbursementAccountForm, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; import type PlaidBankAccount from '@src/types/onyx/PlaidBankAccount'; import type {BankAccountStep, ReimbursementAccountStep, ReimbursementAccountSubStep} from '@src/types/onyx/ReimbursementAccount'; @@ -62,12 +62,6 @@ function clearPlaid(): Promise { return Onyx.set(ONYXKEYS.PLAID_DATA, CONST.PLAID.DEFAULT_DATA); } -function clearInternationalBankAccount() { - return clearPlaid() - .then(() => Onyx.set(ONYXKEYS.CORPAY_FIELDS, null)) - .then(() => Onyx.set(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM_DRAFT, null)); -} - function openPlaidView() { clearPlaid().then(() => ReimbursementAccount.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID)); } @@ -80,7 +74,7 @@ function setPlaidEvent(eventName: string | null) { * Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished. */ function openPersonalBankAccountSetupView(exitReportID?: string, isUserValidated = true) { - clearInternationalBankAccount().then(() => { + clearPlaid().then(() => { if (exitReportID) { Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID}); } @@ -569,7 +563,7 @@ function connectBankAccountManually(bankAccountID: number, bankAccount: PlaidBan /** * Verify the user's identity via Onfido */ -function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoDataWithApplicantID, policyID: string) { +function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoDataWithApplicantID, policyID?: string) { const parameters: VerifyIdentityForBankAccountParams = { bankAccountID, onfidoData: JSON.stringify(onfidoData), @@ -645,56 +639,6 @@ function validatePlaidSelection(values: FormOnyxValues): Form return errorFields; } -function fetchCorpayFields(bankCountry: string, bankCurrency?: string, isWithdrawal?: boolean, isBusinessBankAccount?: boolean) { - API.write( - WRITE_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS, - {countryISO: bankCountry, currency: bankCurrency, isWithdrawal, isBusinessBankAccount}, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, - value: { - isLoading: true, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM_DRAFT, - value: { - bankCountry, - bankCurrency: bankCurrency ?? null, - }, - }, - ], - finallyData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, - value: { - isLoading: false, - }, - }, - ], - }, - ); -} - -function createCorpayBankAccountForWalletFlow(data: InternationalBankAccountForm, classification: string, destinationCountry: string, preferredMethod: string) { - const inputData = { - ...data, - classification, - destinationCountry, - preferredMethod, - setupType: 'manual', - fieldsType: 'international', - country: data.bankCountry, - currency: data.bankCurrency, - }; - // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.BANK_ACCOUNT_CREATE_CORPAY, {isWithdrawal: false, isSavings: true, inputs: JSON.stringify(inputData)}); -} - export { acceptACHContractForBankAccount, addBusinessWebsiteForDraft, @@ -724,10 +668,8 @@ export { updateAddPersonalBankAccountDraft, clearPersonalBankAccountSetupType, validatePlaidSelection, - fetchCorpayFields, - clearReimbursementAccountBankCreation, getCorpayBankAccountFields, - createCorpayBankAccountForWalletFlow, + clearReimbursementAccountBankCreation, }; export type {BusinessAddress, PersonalAddress}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 405976ab425c..bdf2e49e6ddc 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -45,6 +45,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as NextStepUtils from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PerDiemRequestUtils from '@libs/PerDiemRequestUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -499,6 +500,7 @@ function initMoneyRequest( } const comment: Comment = {}; + let requestCategory: string | null = null; // Add initial empty waypoints when starting a distance expense if (newIouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) { @@ -512,6 +514,22 @@ function initMoneyRequest( } } + if (newIouRequestType === CONST.IOU.REQUEST_TYPE.PER_DIEM) { + comment.customUnit = { + attributes: { + dates: { + start: DateUtils.getStartOfToday(), + end: DateUtils.getStartOfToday(), + }, + }, + }; + if (!isFromGlobalCreate) { + const {customUnitID, category} = PerDiemRequestUtils.getCustomUnitID(reportID); + comment.customUnit = {...comment.customUnit, customUnitID}; + requestCategory = category ?? null; + } + } + // Store the transaction in Onyx and mark it as not saved so it can be cleaned up later // Use set() here so that there is no way that data will be leaked between objects when it gets reset Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, { @@ -520,6 +538,7 @@ function initMoneyRequest( comment, created, currency, + category: requestCategory, iouRequestType: newIouRequestType, reportID, transactionID: newTransactionID, @@ -571,6 +590,10 @@ function setMoneyRequestCreated(transactionID: string, created: string, isDraft: Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {created}); } +function setMoneyRequestDateAttribute(transactionID: string, start: string, end: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {attributes: {dates: {start, end}}}}}); +} + function setMoneyRequestCurrency(transactionID: string, currency: string, isEditing = false) { const fieldToUpdate = isEditing ? 'modifiedCurrency' : 'currency'; Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {[fieldToUpdate]: currency}); @@ -637,6 +660,115 @@ function setCustomUnitRateID(transactionID: string, customUnitRateID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {customUnitRateID}}}); } +/** + * Set custom unit ID for the transaction draft + */ +function setCustomUnitID(transactionID: string, customUnitID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {customUnitID}}}); +} + +function removeSubrate(transaction: OnyxEntry, currentIndex: string) { + // Index comes from the route params and is a string + const index = Number(currentIndex); + if (index === -1) { + return; + } + const existingSubrates = transaction?.comment?.customUnit?.subRates ?? []; + + const newSubrates = [...existingSubrates]; + newSubrates.splice(index, 1); + + // Onyx.merge won't remove the null nested object values, this is a workaround + // to remove nested keys while also preserving other object keys + // Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set + const newTransaction: OnyxTypes.Transaction = { + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + ...(transaction as OnyxTypes.Transaction), + comment: { + ...transaction?.comment, + customUnit: { + ...transaction?.comment?.customUnit, + subRates: newSubrates, + quantity: null, + }, + }, + }; + + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`, newTransaction); +} + +function updateSubrate(transaction: OnyxEntry, currentIndex: string, quantity: number, id: string, name: string, rate: number) { + // Index comes from the route params and is a string + const index = Number(currentIndex); + if (index === -1) { + return; + } + const existingSubrates = transaction?.comment?.customUnit?.subRates ?? []; + + if (index >= existingSubrates.length) { + return; + } + + const newSubrates = [...existingSubrates]; + newSubrates.splice(index, 1, {quantity, id, name, rate}); + + // Onyx.merge won't remove the null nested object values, this is a workaround + // to remove nested keys while also preserving other object keys + // Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set + const newTransaction: OnyxTypes.Transaction = { + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + ...(transaction as OnyxTypes.Transaction), + comment: { + ...transaction?.comment, + customUnit: { + ...transaction?.comment?.customUnit, + subRates: newSubrates, + quantity: null, + }, + }, + }; + + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`, newTransaction); +} + +function clearSubrates(transactionID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {subRates: []}}}); +} + +function addSubrate(transaction: OnyxEntry, currentIndex: string, quantity: number, id: string, name: string, rate: number) { + // Index comes from the route params and is a string + const index = Number(currentIndex); + if (index === -1) { + return; + } + const existingSubrates = transaction?.comment?.customUnit?.subRates ?? []; + + if (index !== existingSubrates.length) { + return; + } + + const newSubrates = [...existingSubrates]; + newSubrates.push({quantity, id, name, rate}); + + // Onyx.merge won't remove the null nested object values, this is a workaround + // to remove nested keys while also preserving other object keys + // Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set + const newTransaction: OnyxTypes.Transaction = { + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + ...(transaction as OnyxTypes.Transaction), + comment: { + ...transaction?.comment, + customUnit: { + ...transaction?.comment?.customUnit, + subRates: newSubrates, + quantity: null, + }, + }, + }; + + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`, newTransaction); +} + /** Set the distance rate of a new transaction */ function setMoneyRequestDistanceRate(transactionID: string, rateID: string, policyID: string, isDraft: boolean) { Onyx.merge(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, {[policyID]: rateID}); @@ -2566,7 +2698,7 @@ function getTrackExpenseInformation( if (!filename) { filename = existingTransaction?.filename; } - const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; + const isDistanceRequest = existingTransaction && TransactionUtils.isDistanceRequest(existingTransaction); let optimisticTransaction = TransactionUtils.buildOptimisticTransaction({ existingTransactionID, existingTransaction, @@ -6979,6 +7111,7 @@ function getPayMoneyRequestParams( reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, partial: full ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, + errors: null, }, }, { @@ -7002,6 +7135,7 @@ function getPayMoneyRequestParams( reimbursed: null, partial: null, }, + errors: null, }, }); @@ -8871,6 +9005,11 @@ export { sendMoneyElsewhere, sendMoneyWithWallet, setCustomUnitRateID, + setCustomUnitID, + removeSubrate, + addSubrate, + updateSubrate, + clearSubrates, setDraftSplitTransaction, setIndividualShare, setMoneyRequestAmount, @@ -8878,6 +9017,7 @@ export { setMoneyRequestBillable, setMoneyRequestCategory, setMoneyRequestCreated, + setMoneyRequestDateAttribute, setMoneyRequestCurrency, setMoneyRequestDescription, setMoneyRequestDistanceRate, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 5bf6f6e1e928..f18fac18aca2 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -38,6 +38,7 @@ import type { OpenWorkspaceInvitePageParams, OpenWorkspaceParams, RequestExpensifyCardLimitIncreaseParams, + SetNameValuePairParams, SetPolicyAutomaticApprovalLimitParams, SetPolicyAutomaticApprovalRateParams, SetPolicyAutoReimbursementLimitParams, @@ -824,6 +825,37 @@ function leaveWorkspace(policyID?: string) { API.write(WRITE_COMMANDS.LEAVE_POLICY, params, {optimisticData, successData, failureData}); } +function updateDefaultPolicy(newPolicyID?: string, oldPolicyID?: string) { + if (!newPolicyID) { + return; + } + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, + value: newPolicyID, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, + value: oldPolicyID, + }, + ]; + + const parameters: SetNameValuePairParams = { + name: ONYXKEYS.NVP_ACTIVE_POLICY_ID, + value: newPolicyID, + }; + + API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIR, parameters, { + optimisticData, + failureData, + }); +} + function addBillingCardAndRequestPolicyOwnerChange( policyID: string, cardData: { @@ -1196,7 +1228,7 @@ function clearAvatarErrors(policyID: string) { * Optimistically update the general settings. Set the general settings as pending until the response succeeds. * If the response fails set a general error message. Clear the error message when updating. */ -function updateGeneralSettings(name: string, currencyValue?: string, policyID?: string) { +function updateGeneralSettings(policyID: string, name: string, currencyValue?: string) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; if (!policy || !policyID) { return; @@ -4797,6 +4829,7 @@ export { verifySetupIntentAndRequestPolicyOwnerChange, updateInvoiceCompanyName, updateInvoiceCompanyWebsite, + updateDefaultPolicy, getAssignedSupportData, downgradeToTeam, }; diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index 204e27e1266e..3514367845b3 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -34,6 +34,12 @@ function flushQueue(): Promise { ONYXKEYS.IS_LOADING_APP, ONYXKEYS.CREDENTIALS, ONYXKEYS.IS_SIDEBAR_LOADED, + ONYXKEYS.ACCOUNT, + ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, + ONYXKEYS.MODAL, + ONYXKEYS.NETWORK, + ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, + ONYXKEYS.PRESERVED_USER_SESSION, ]; queuedOnyxUpdates = queuedOnyxUpdates.filter((update) => preservedKeys.includes(update.key as OnyxKey)); diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 49c294946d09..1e157b983483 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2359,6 +2359,13 @@ function addPolicyReport(policyReport: ReportUtils.OptimisticChatReport) { key: ONYXKEYS.FORMS.NEW_ROOM_FORM, value: {isLoading: true}, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${policyReport.reportID}`, + value: { + isOptimisticReport: true, + }, + }, ]; const successData: OnyxUpdate[] = [ { @@ -2407,6 +2414,13 @@ function addPolicyReport(policyReport: ReportUtils.OptimisticChatReport) { key: ONYXKEYS.FORMS.NEW_ROOM_FORM, value: {isLoading: false}, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${policyReport.reportID}`, + value: { + isOptimisticReport: false, + }, + }, ]; const parameters: AddWorkspaceRoomParams = { @@ -4012,24 +4026,78 @@ function prepareOnboardingOptimisticData( guidedSetupData.push({type: 'video', ...data.video, ...videoMessage}); } - if (engagementChoice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM) { + type SelfDMParameters = { + reportID?: string; + createdReportActionID?: string; + }; + + let selfDMParameters: SelfDMParameters = {}; + if (engagementChoice === CONST.ONBOARDING_CHOICES.PERSONAL_SPEND) { const selfDMReportID = ReportUtils.findSelfDMReportID(); - const selfDMReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`]; - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`, - value: { - isPinned: false, - }, - }); + let selfDMReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`]; + let createdAction: ReportAction; + if (selfDMReport) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`, + value: { + isPinned: true, + }, + }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`, - value: { - isPinned: selfDMReport?.isPinned, - }, - }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`, + value: { + isPinned: selfDMReport?.isPinned, + }, + }); + } else { + const currentTime = DateUtils.getDBTime(); + selfDMReport = ReportUtils.buildOptimisticSelfDMReport(currentTime); + createdAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail ?? '', currentTime); + selfDMParameters = {reportID: selfDMReport.reportID, createdReportActionID: createdAction.reportActionID}; + optimisticData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, + value: { + ...selfDMReport, + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, + value: { + [createdAction.reportActionID]: createdAction, + }, + }, + ); + + successData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, + value: { + pendingFields: { + createChat: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, + value: { + [createdAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + ); + } } optimisticData.push({ @@ -4060,7 +4128,7 @@ function prepareOnboardingOptimisticData( guidedSetupData.push(...tasksForParameters, {type: 'message', ...welcomeSignOffMessage}); - return {optimisticData, successData, failureData, guidedSetupData, actorAccountID}; + return {optimisticData, successData, failureData, guidedSetupData, actorAccountID, selfDMParameters}; } function completeOnboarding( @@ -4075,7 +4143,7 @@ function completeOnboarding( userReportedIntegration?: OnboardingAccounting, wasInvited?: boolean, ) { - const {optimisticData, successData, failureData, guidedSetupData, actorAccountID} = prepareOnboardingOptimisticData( + const {optimisticData, successData, failureData, guidedSetupData, actorAccountID, selfDMParameters} = prepareOnboardingOptimisticData( engagementChoice, data, adminsChatReportID, @@ -4094,6 +4162,8 @@ function completeOnboarding( companySize, userReportedIntegration, policyID: onboardingPolicyID, + selfDMReportID: selfDMParameters.reportID, + selfDMCreatedReportActionID: selfDMParameters.createdReportActionID, }; API.write(WRITE_COMMANDS.COMPLETE_GUIDED_SETUP, parameters, {optimisticData, successData, failureData}); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index ff0b644bdaee..61597eb5949d 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -8,6 +8,7 @@ import * as API from '@libs/API'; import type {ExportSearchItemsToCSVParams, SubmitReportParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ApiUtils from '@libs/ApiUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import enhanceParameters from '@libs/Network/enhanceParameters'; import {rand64} from '@libs/NumberUtils'; @@ -299,7 +300,7 @@ function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], trans }, ]; const optimisticData: OnyxUpdate[] = createOnyxData({isActionLoading: true}); - const failureData: OnyxUpdate[] = createOnyxData({hasError: true}); + const failureData: OnyxUpdate[] = createOnyxData({errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}); const finallyData: OnyxUpdate[] = createOnyxData({isActionLoading: false}); API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH, {hash, reportIDList}, {optimisticData, failureData, finallyData}); @@ -319,7 +320,7 @@ function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], trans ]; const optimisticData: OnyxUpdate[] = createOnyxData({isActionLoading: true}); - const failureData: OnyxUpdate[] = createOnyxData({hasError: true}); + const failureData: OnyxUpdate[] = createOnyxData({errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}); const finallyData: OnyxUpdate[] = createOnyxData({isActionLoading: false}); // eslint-disable-next-line rulesdir/no-api-side-effects-method diff --git a/src/pages/ReimbursementAccount/BankAccountStep.tsx b/src/pages/ReimbursementAccount/BankAccountStep.tsx index 120cc14b9afb..95d8f2c39663 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.tsx +++ b/src/pages/ReimbursementAccount/BankAccountStep.tsx @@ -213,11 +213,11 @@ function BankAccountStep({ {translate('common.privacy')} Link.openExternalLink(CONST.ENCRYPTION_AND_SECURITY_HELP_URL)} + onPress={() => Link.openExternalLink('https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security/')} style={[styles.flexRow, styles.alignItemsCenter]} accessibilityLabel={translate('bankAccount.yourDataIsSecure')} > - {translate('bankAccount.yourDataIsSecure')} + {translate('bankAccount.yourDataIsSecure')} ; + + /** The application ID for our Onfido instance */ + onfidoApplicantID: OnyxEntry; +}; + +type RequestorOnfidoStepProps = RequestorOnfidoStepOnyxProps & { + /** The bank account currently in setup */ + reimbursementAccount: ReimbursementAccount; + + /** Goes to the previous step */ + onBackButtonPress: () => void; +}; + +const HEADER_STEP_COUNTER = {step: 3, total: 5}; +const ONFIDO_ERROR_DISPLAY_DURATION = 10000; + +function RequestorOnfidoStep({onBackButtonPress, reimbursementAccount, onfidoToken, onfidoApplicantID}: RequestorOnfidoStepProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const submitOnfidoData = (onfidoData: OnfidoData) => { + BankAccounts.verifyIdentityForBankAccount(reimbursementAccount.achData?.bankAccountID ?? -1, { + ...onfidoData, + applicantID: onfidoApplicantID ?? '-1', + }); + BankAccounts.updateReimbursementAccountDraft({isOnfidoSetupComplete: true}); + }; + + const handleOnfidoError = () => { + // In case of any unexpected error we log it to the server, show a growl, and return the user back to the requestor step so they can try again. + Growl.error(translate('onfidoStep.genericError'), ONFIDO_ERROR_DISPLAY_DURATION); + BankAccounts.clearOnfidoToken(); + BankAccounts.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR); + }; + + const handleOnfidoUserExit = () => { + BankAccounts.clearOnfidoToken(); + BankAccounts.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR); + }; + + return ( + + + + + + + + + ); +} + +RequestorOnfidoStep.displayName = 'RequestorOnfidoStep'; + +export default withOnyx({ + onfidoToken: { + key: ONYXKEYS.ONFIDO_TOKEN, + }, + onfidoApplicantID: { + key: ONYXKEYS.ONFIDO_APPLICANT_ID, + }, +})(RequestorOnfidoStep); diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 4a674d6e80a1..166b12b27751 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -279,6 +279,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta }, [isMoneyRequestExported, moneyRequestReport, isDelegateAccessRestricted]); const shouldShowLeaveButton = ReportUtils.canLeaveChat(report, policy); + const shouldShowGoToWorkspace = PolicyUtils.shouldShowPolicy(policy, false, session?.email) && !policy?.isJoinRequestPending; const reportName = ReportUtils.getReportName(report); @@ -456,23 +457,6 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta }); } - if (shouldShowLeaveButton) { - items.push({ - key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM, - translationKey: 'common.leave', - icon: Expensicons.Exit, - isAnonymousAction: true, - action: () => { - if (ReportUtils.getParticipantsAccountIDsForDisplay(report, false, true).length === 1 && isRootGroupChat) { - setIsLastMemberLeavingGroupModalVisible(true); - return; - } - - leaveChat(); - }, - }); - } - if (isMoneyRequestReport) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.DOWNLOAD, @@ -514,6 +498,43 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta }); } + if (shouldShowGoToWorkspace) { + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.GO_TO_WORKSPACE, + translationKey: 'workspace.common.goToWorkspace', + icon: Expensicons.Building, + action: () => { + if (!report?.policyID) { + return; + } + if (isSmallScreenWidth) { + Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(report?.policyID)); + return; + } + Navigation.navigate(ROUTES.WORKSPACE_PROFILE.getRoute(report?.policyID)); + }, + isAnonymousAction: false, + shouldShowRightIcon: true, + }); + } + + if (shouldShowLeaveButton) { + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM, + translationKey: 'common.leave', + icon: Expensicons.Exit, + isAnonymousAction: true, + action: () => { + if (ReportUtils.getParticipantsAccountIDsForDisplay(report, false, true).length === 1 && isRootGroupChat) { + setIsLastMemberLeavingGroupModalVisible(true); + return; + } + + leaveChat(); + }, + }); + } + if (report?.reportID && isDebugModeEnabled) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.DEBUG, @@ -530,7 +551,6 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta isSelfDM, isArchivedRoom, isGroupChat, - isRootGroupChat, isDefaultRoom, isChatThread, isPolicyEmployee, @@ -539,35 +559,38 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta report, isSystemChat, isPolicyExpenseChat, + shouldShowMenuItem, + isTrackExpenseReport, + isDeletedParentAction, isMoneyRequestReport, isInvoiceReport, + isTaskReport, + isCanceledTaskReport, + shouldShowCancelPaymentButton, + shouldShowLeaveButton, policy, connectedIntegration, isPolicyAdmin, isSingleTransactionView, - canModifyTask, - shouldShowMenuItem, - isTaskReport, - isCanceledTaskReport, - shouldShowLeaveButton, + isExpenseReport, + canUnapproveRequest, + isDebugModeEnabled, + shouldShowGoToWorkspace, activeChatMembers.length, shouldOpenRoomMembersPage, - shouldShowCancelPaymentButton, + backTo, + parentReportAction, + iouTransactionID, + moneyRequestReport?.reportID, session, + canModifyTask, + canActionTask, + isRootGroupChat, + leaveChat, isOffline, transactionIDList, - leaveChat, - canUnapproveRequest, - isDebugModeEnabled, unapproveExpenseReportOrShowModal, - isExpenseReport, - backTo, - canActionTask, - isTrackExpenseReport, - iouTransactionID, - parentReportAction, - moneyRequestReport?.reportID, - isDeletedParentAction, + isSmallScreenWidth, ]); const displayNamesWithTooltips = useMemo(() => { diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx index 80995c95c741..8d973a262186 100644 --- a/src/pages/TransactionDuplicate/Confirmation.tsx +++ b/src/pages/TransactionDuplicate/Confirmation.tsx @@ -71,7 +71,6 @@ function Confirmation() { action: reportAction, report, checkIfContextMenuActive: () => {}, - onShowContextMenu: () => {}, reportNameValuePairs: undefined, anchor: null, isDisabled: false, diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index ea0961701a52..73fed14af87c 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -383,7 +383,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro !isCurrentReportLoadedFromOnyx || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (deleteTransactionNavigateBackUrl && ReportUtils.getReportIDFromLink(deleteTransactionNavigateBackUrl) === report?.reportID) || - isLoading; + (!reportMetadata.isOptimisticReport && isLoading); const isLinkedActionBecomesDeleted = prevIsLinkedActionDeleted !== undefined && !prevIsLinkedActionDeleted && isLinkedActionDeleted; diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 451d82a770d5..6877de271946 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -1,12 +1,11 @@ import lodashIsEqual from 'lodash/isEqual'; import type {MutableRefObject, RefObject} from 'react'; -import React, {memo, useContext, useMemo, useRef, useState} from 'react'; +import React, {memo, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Text as RNText, View as ViewType} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {ContextMenuItemHandle} from '@components/ContextMenuItem'; import ContextMenuItem from '@components/ContextMenuItem'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; @@ -119,7 +118,6 @@ function BaseReportActionContextMenu({ disabledActions = [], setIsEmojiPickerActive, }: BaseReportActionContextMenuProps) { - const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -330,7 +328,6 @@ function BaseReportActionContextMenu({ draftMessage, selection, close: () => setShouldKeepOpen(false), - transitionActionSheetState: actionSheetAwareScrollViewContext.transitionActionSheetState, openContextMenu: () => setShouldKeepOpen(true), interceptAnonymousUser, openOverflowMenu, diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 253e35630add..b8cdde2ecff3 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -31,8 +31,8 @@ import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {Beta, Download as DownloadOnyx, OnyxInputOrEntry, ReportAction, ReportActionReactions, Report as ReportType, Transaction, User} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; -import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; import type {ContextMenuAnchor} from './ReportActionContextMenu'; +import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; /** Gets the HTML version of the message in an action */ function getActionHtml(reportAction: OnyxInputOrEntry): string { @@ -79,7 +79,6 @@ type ContextMenuActionPayload = { draftMessage: string; selection: string; close: () => void; - transitionActionSheetState: (params: {type: string; payload?: Record}) => void; openContextMenu: () => void; interceptAnonymousUser: (callback: () => void, isAnonymousAction?: boolean) => void; anchor?: MutableRefObject; diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 5dbd399e2737..bb3e04a90b84 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -5,7 +5,6 @@ import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {Emoji} from '@assets/emojis/types'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import {AttachmentContext} from '@components/AttachmentContext'; import Button from '@components/Button'; import DisplayNames from '@components/DisplayNames'; @@ -295,7 +294,6 @@ function PureReportActionItem({ userBillingFundID, reportAutomaticallyForwardedMessage, }: PureReportActionItemProps) { - const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const reportID = report?.reportID ?? ''; @@ -433,34 +431,7 @@ function PureReportActionItem({ const toggleContextMenuFromActiveReportAction = useCallback(() => { setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(action.reportActionID)); - - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.CLOSE_POPOVER, - }); - }, [actionSheetAwareScrollViewContext, action.reportActionID]); - - const handleShowContextMenu = useCallback( - (callback: () => void) => { - if (!(popoverAnchorRef.current && 'measureInWindow' in popoverAnchorRef.current)) { - return; - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - popoverAnchorRef.current?.measureInWindow((_fx, fy, _width, height) => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.OPEN_POPOVER, - payload: { - popoverHeight: 0, - fy, - height, - }, - }); - - callback(); - }); - }, - [actionSheetAwareScrollViewContext], - ); + }, [action.reportActionID]); const disabledActions = useMemo(() => (!ReportUtils.canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []), [report]); @@ -476,31 +447,29 @@ function PureReportActionItem({ return; } - handleShowContextMenu(() => { - setIsContextMenuActive(true); - const selection = SelectionScraper.getCurrentSelection(); - ReportActionContextMenu.showContextMenu( - CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - popoverAnchorRef.current, - reportID, - action.reportActionID, - originalReportID, - draftMessage ?? '', - () => setIsContextMenuActive(true), - toggleContextMenuFromActiveReportAction, - isArchivedRoom, - isChronosReport, - false, - false, - disabledActions, - false, - setIsEmojiPickerActive as () => void, - undefined, - isThreadReportParentAction, - ); - }); + setIsContextMenuActive(true); + const selection = SelectionScraper.getCurrentSelection(); + ReportActionContextMenu.showContextMenu( + CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + selection, + popoverAnchorRef.current, + reportID, + action.reportActionID, + originalReportID, + draftMessage ?? '', + () => setIsContextMenuActive(true), + toggleContextMenuFromActiveReportAction, + isArchivedRoom, + isChronosReport, + false, + false, + disabledActions, + false, + setIsEmojiPickerActive as () => void, + undefined, + isThreadReportParentAction, + ); }, [ draftMessage, @@ -512,7 +481,6 @@ function PureReportActionItem({ disabledActions, isArchivedRoom, isChronosReport, - handleShowContextMenu, isThreadReportParentAction, ], ); @@ -544,10 +512,9 @@ function PureReportActionItem({ action, transactionThreadReport, checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, - onShowContextMenu: handleShowContextMenu, isDisabled: false, }), - [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, handleShowContextMenu, reportNameValuePairs], + [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, reportNameValuePairs], ); const attachmentContextValue = useMemo(() => ({reportID, type: CONST.ATTACHMENT_TYPE.REPORT}), [reportID]); @@ -698,7 +665,6 @@ function PureReportActionItem({ isMostRecentIOUReportAction={isMostRecentIOUReportAction} isHovered={hovered} contextMenuAnchor={popoverAnchorRef.current} - onShowContextMenu={handleShowContextMenu} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} style={displayAsGroup ? [] : [styles.mt2]} isWhisper={isWhisper} @@ -721,13 +687,13 @@ function PureReportActionItem({ ${translate('parentReportAction.deletedReport')}`} /> ) : ( setIsPaymentMethodPopoverActive(true)} @@ -746,7 +712,6 @@ function PureReportActionItem({ chatReportID={reportID} action={action} isHovered={hovered} - onShowContextMenu={handleShowContextMenu} contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} policyID={report?.policyID ?? '-1'} diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 24d7da266eb0..03d71d959a0f 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,14 +1,13 @@ import {useNavigation} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; import noop from 'lodash/noop'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import {runOnUI, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {FileObject} from '@components/AttachmentModal'; import AttachmentModal from '@components/AttachmentModal'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; @@ -126,7 +125,6 @@ function ReportActionCompose({ setShowSoftInputOnFocus, didHideComposerInput, }: ReportActionComposeProps) { - const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -386,18 +384,6 @@ function ReportActionCompose({ clearComposer(); }, [isSendDisabled, isReportReadyForDisplay, composerRefShared]); - const measureComposer = useCallback( - (e: LayoutChangeEvent) => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.MEASURE_COMPOSER, - payload: { - composerHeight: e.nativeEvent.layout.height, - }, - }); - }, - [actionSheetAwareScrollViewContext], - ); - // eslint-disable-next-line react-compiler/react-compiler onSubmitAction = handleSendMessage; @@ -440,10 +426,7 @@ function ReportActionCompose({ {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + { + return action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + }; + // If no unread marker exists, don't set an unread marker for newly added messages from the current user. const isFromCurrentUser = accountID === (ReportActionsUtils.isReportPreviewAction(message) ? message.childLastActorAccountID : message.actorAccountID); const isNewMessage = !prevSortedVisibleReportActionsObjects[message.reportActionID]; @@ -284,10 +287,16 @@ function ReportActionsList({ // The `unreadMarkerTime` has already been updated to match the optimistic action created time, // but once the new action is saved on the backend, the actual created time will be later than the optimistic one. // Therefore, we also need to prevent the unread marker from appearing for previously optimistic actions. - const isPreviouslyOptimistic = !!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction; + const isPreviouslyOptimistic = + (isPendingAdd(prevSortedVisibleReportActionsObjects[message.reportActionID]) && !isPendingAdd(message)) || + (!!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction); const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID.current && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic); - return !shouldIgnoreUnreadForCurrentUserMessage; + if (isFromCurrentUser) { + return !shouldIgnoreUnreadForCurrentUserMessage; + } + + return !isNewMessage || scrollingVerticalOffset.current >= MSG_VISIBLE_THRESHOLD; }; // If there are message that were recevied while offline, @@ -332,7 +341,7 @@ function ReportActionsList({ * the MSG_VISIBLE_THRESHOLD), the unread marker will display over those new messages rather than the initial * lastReadTime. */ - useEffect(() => { + useLayoutEffect(() => { if (unreadMarkerReportActionID) { return; } @@ -729,7 +738,6 @@ function ReportActionsList({ style={styles.overscrollBehaviorContain} data={sortedVisibleReportActions} renderItem={renderItem} - renderScrollComponent={ActionSheetAwareScrollView.renderScrollComponent} contentContainerStyle={contentContainerStyle} keyExtractor={keyExtractor} initialNumToRender={initialNumToRender} diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index 78dac9220742..0c3b0022c89a 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -14,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {IOURequestType} from '@userActions/IOU'; @@ -24,7 +25,9 @@ import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import IOURequestStepAmount from './step/IOURequestStepAmount'; +import IOURequestStepDestination from './step/IOURequestStepDestination'; import IOURequestStepDistance from './step/IOURequestStepDistance'; +import IOURequestStepPerDiemWorkspace from './step/IOURequestStepPerDiemWorkspace'; import IOURequestStepScan from './step/IOURequestStepScan'; import type {WithWritableReportOrNotFoundProps} from './step/withWritableReportOrNotFound'; @@ -44,11 +47,12 @@ function IOURequestStartPage({ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const policy = usePolicy(report?.policyID); const [selectedTab = CONST.TAB_REQUEST.SCAN, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.IOU_REQUEST_TYPE}`); + const [session] = useOnyx(ONYXKEYS.SESSION); const isLoadingSelectedTab = shouldUseTab ? isLoadingOnyxValue(selectedTabResult) : false; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID || -1}`); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID || CONST.DEFAULT_NUMBER_ID}`); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const {canUseCombinedTrackSubmit} = usePermissions(); + const {canUseCombinedTrackSubmit, canUsePerDiem} = usePermissions(); const tabTitles = { [CONST.IOU.TYPE.REQUEST]: translate('iou.createExpense'), @@ -96,6 +100,24 @@ function IOURequestStartPage({ return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element) as HTMLElement[]; }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); + const perDiemCustomUnits = PolicyUtils.getActivePolicies(allPolicies, session?.email) + .map((mappedPolicy) => ({policyID: mappedPolicy.id, customUnit: PolicyUtils.getPerDiemCustomUnit(mappedPolicy)})) + .filter(({customUnit}) => !isEmptyObject(customUnit) && !!customUnit.enabled); + + const doesPerDiemPolicyExist = perDiemCustomUnits.length > 0; + + const moreThanOnePerDiemExist = perDiemCustomUnits.length > 1; + + const currentPolicyPerDiemUnit = PolicyUtils.getPerDiemCustomUnit(policy); + + const doesCurrentPolicyPerDiemExist = !isEmptyObject(currentPolicyPerDiemUnit) && !!currentPolicyPerDiemUnit.enabled; + + const shouldShowPerDiemOption = + iouType !== CONST.IOU.TYPE.SPLIT && + iouType !== CONST.IOU.TYPE.TRACK && + canUsePerDiem && + ((!isFromGlobalCreate && doesCurrentPolicyPerDiemExist) || (isFromGlobalCreate && doesPerDiemPolicyExist)); + if (!transaction?.transactionID) { // The draft transaction is initialized only after the component is mounted, // which will lead to briefly displaying the Not Found page without this loader. @@ -140,6 +162,7 @@ function IOURequestStartPage({ tabBar={TabSelector} onTabBarFocusTrapContainerElementChanged={setTabBarContainerElement} onActiveTabFocusTrapContainerElementChanged={setActiveTabContainerElement} + shouldShowLabelWhenInactive={!shouldShowPerDiemOption} > {() => ( @@ -172,6 +195,27 @@ function IOURequestStartPage({ )} + {!!shouldShowPerDiemOption && ( + + {() => ( + + {moreThanOnePerDiemExist && !doesCurrentPolicyPerDiemExist ? ( + + ) : ( + + )} + + )} + + )} ) : ( void; - /** Callback to navigate to Track Expense confirmation flow */ - onTrackExpensePress?: () => void; - /** Selected participants from MoneyRequestModal with login */ participants?: Participant[] | typeof CONST.EMPTY_ARRAY; @@ -53,20 +49,9 @@ type MoneyRequestParticipantsSelectorProps = { /** The action of the IOU, i.e. create, split, move */ action: IOUAction; - - /** Whether we should display the Track Expense button at the top of the participants list */ - shouldDisplayTrackExpenseButton?: boolean; }; -function MoneyRequestParticipantsSelector({ - participants = CONST.EMPTY_ARRAY, - onTrackExpensePress, - onFinish, - onParticipantsAdded, - iouType, - action, - shouldDisplayTrackExpenseButton, -}: MoneyRequestParticipantsSelectorProps) { +function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onFinish, onParticipantsAdded, iouType, action}: MoneyRequestParticipantsSelectorProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -123,6 +108,9 @@ function MoneyRequestParticipantsSelector({ includeP2P: !isCategorizeOrShareAction, includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, action, + shouldSeparateSelfDMChat: true, + shouldSeparateWorkspaceChat: true, + includeSelfDM: true, }, ); @@ -142,6 +130,8 @@ function MoneyRequestParticipantsSelector({ personalDetails: [], currentUserOption: null, headerMessage: '', + workspaceChats: [], + selfDMChat: null, }; } @@ -168,7 +158,7 @@ function MoneyRequestParticipantsSelector({ const formatResults = OptionsListUtils.formatSectionsFromSearchTerm( debouncedSearchTerm, - participants.map((participant) => ({...participant, reportID: participant.reportID ?? CONST.DEFAULT_NUMBER_ID.toString()})), + participants.map((participant) => ({...participant, reportID: participant.reportID})) as ReportUtils.OptionData[], chatOptions.recentReports, chatOptions.personalDetails, personalDetails, @@ -177,6 +167,17 @@ function MoneyRequestParticipantsSelector({ newSections.push(formatResults.section); + newSections.push({ + title: translate('workspace.common.workspace'), + data: chatOptions.workspaceChats ?? [], + shouldShow: (chatOptions.workspaceChats ?? []).length > 0, + }); + newSections.push({ + title: translate('workspace.invoices.paymentMethods.personal'), + data: chatOptions.selfDMChat ? [chatOptions.selfDMChat] : [], + shouldShow: !!chatOptions.selfDMChat, + }); + newSections.push({ title: translate('common.recents'), data: chatOptions.recentReports, @@ -208,7 +209,7 @@ function MoneyRequestParticipantsSelector({ } const headerMessage = OptionsListUtils.getHeaderMessage( - (chatOptions.personalDetails ?? []).length + (chatOptions.recentReports ?? []).length !== 0, + (chatOptions.personalDetails ?? []).length + (chatOptions.recentReports ?? []).length + (chatOptions.workspaceChats ?? []).length !== 0 || !isEmptyObject(chatOptions.selfDMChat), !!chatOptions?.userToInvite, debouncedSearchTerm.trim(), participants.some((participant) => OptionsListUtils.getPersonalDetailSearchTerms(participant).join(' ').toLowerCase().includes(cleanSearchTerm)), @@ -222,6 +223,8 @@ function MoneyRequestParticipantsSelector({ participants, chatOptions.recentReports, chatOptions.personalDetails, + chatOptions.selfDMChat, + chatOptions.workspaceChats, chatOptions.userToInvite, personalDetails, translate, @@ -237,7 +240,7 @@ function MoneyRequestParticipantsSelector({ (option: Participant) => { const newParticipants: Participant[] = [ { - ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText', 'policyID'), + ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText', 'policyID', 'isSelfDM', 'text', 'phoneNumber'), selected: true, iouType, }, @@ -254,7 +257,10 @@ function MoneyRequestParticipantsSelector({ } onParticipantsAdded(newParticipants); - onFinish(); + + if (!option.isSelfDM) { + onFinish(); + } }, // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to trigger this callback when iouType changes [onFinish, onParticipantsAdded, currentUserLogin], @@ -340,6 +346,7 @@ function MoneyRequestParticipantsSelector({ sections.forEach((section) => { length += section.data.length; }); + return length; }, [areOptionsInitialized, sections]); @@ -347,22 +354,6 @@ function MoneyRequestParticipantsSelector({ const shouldShowReferralBanner = !isDismissed && iouType !== CONST.IOU.TYPE.INVOICE && !shouldShowListEmptyContent; - const headerContent = useMemo(() => { - if (!shouldDisplayTrackExpenseButton) { - return; - } - - // We only display the track expense button if the user is coming from the combined submit/track flow. - return ( - - ); - }, [shouldDisplayTrackExpenseButton, translate, onTrackExpensePress]); - const footerContent = useMemo(() => { if (isDismissed && !shouldShowSplitBillErrorMessage && !participants.length) { return; @@ -448,7 +439,6 @@ function MoneyRequestParticipantsSelector({ shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} onSelectRow={onSelectRow} shouldSingleExecuteRowSelect - headerContent={headerContent} footerContent={footerContent} listEmptyContent={ >(); const requestType = TransactionUtils.getRequestType(transaction); const isDistanceRequest = requestType === CONST.IOU.REQUEST_TYPE.DISTANCE; + const isPerDiemRequest = requestType === CONST.IOU.REQUEST_TYPE.PER_DIEM; const [lastLocationPermissionPrompt] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT); const receiptFilename = transaction?.filename; @@ -179,6 +180,10 @@ function IOURequestStepConfirmation({ Navigation.goBack(); return; } + if (isPerDiemRequest) { + Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_SUBRATE.getRoute(action, iouType, transactionID, reportID)); + return; + } // If there is not a report attached to the IOU with a reportID, then the participants were manually selected and the user needs taken // back to the participants step if (!transaction?.participantsAutoAssigned && participantsAutoAssignedFromRoute !== 'true') { @@ -187,7 +192,7 @@ function IOURequestStepConfirmation({ return; } IOUUtils.navigateToStartMoneyRequestStep(requestType, iouType, transactionID, reportID, action); - }, [transaction, iouType, requestType, transactionID, reportID, action, participantsAutoAssignedFromRoute]); + }, [action, isPerDiemRequest, transaction?.participantsAutoAssigned, transaction?.reportID, participantsAutoAssignedFromRoute, requestType, iouType, transactionID, reportID]); const navigateToAddReceipt = useCallback(() => { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); @@ -355,6 +360,9 @@ function IOURequestStepConfirmation({ const createTransaction = useCallback( (selectedParticipants: Participant[], locationPermissionGranted = false) => { setIsConfirmed(true); + if (isPerDiemRequest) { + return; + } let splitParticipants = selectedParticipants; // Filter out participants with an amount equal to O @@ -524,24 +532,25 @@ function IOURequestStepConfirmation({ requestMoney(selectedParticipants, trimmedComment); }, [ - transaction, - report, + isPerDiemRequest, iouType, - receiptFile, + transaction, isDistanceRequest, + isMovingTransactionFromTrackExpense, + receiptFile, + isCategorizingTrackExpense, + isSharingTrackExpense, requestMoney, + createDistanceRequest, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - trackExpense, - createDistanceRequest, - isSharingTrackExpense, - isCategorizingTrackExpense, - isMovingTransactionFromTrackExpense, + report, + transactionTaxCode, + transactionTaxAmount, policy, policyTags, policyCategories, - transactionTaxAmount, - transactionTaxCode, + trackExpense, ], ); @@ -660,6 +669,7 @@ function IOURequestStepConfirmation({ iouMerchant={transaction?.merchant} iouCreated={transaction?.created} isDistanceRequest={isDistanceRequest} + isPerDiemRequest={isPerDiemRequest} shouldShowSmartScanFields={isMovingTransactionFromTrackExpense ? transaction?.amount !== 0 : requestType !== CONST.IOU.REQUEST_TYPE.SCAN} action={action} payeePersonalDetails={payeePersonalDetails} diff --git a/src/pages/iou/request/step/IOURequestStepDestination.tsx b/src/pages/iou/request/step/IOURequestStepDestination.tsx new file mode 100644 index 000000000000..bec4d3862f68 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDestination.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import {ActivityIndicator, InteractionManager, View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import Button from '@components/Button'; +import DestinationPicker from '@components/DestinationPicker'; +import FixedFooter from '@components/FixedFooter'; +import * as Illustrations from '@components/Icon/Illustrations'; +import type {ListItem} from '@components/SelectionList/types'; +import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as IOU from '@userActions/IOU'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import StepScreenWrapper from './StepScreenWrapper'; +import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +type IOURequestStepDestinationProps = WithWritableReportOrNotFoundProps & + WithFullTransactionOrNotFoundProps & { + openedFromStartPage?: boolean; + explicitPolicyID?: string; + }; + +function IOURequestStepDestination({ + report, + route: { + params: {transactionID, backTo, action, iouType, reportID}, + }, + transaction, + openedFromStartPage = false, + explicitPolicyID, +}: IOURequestStepDestinationProps) { + const [policy, policyMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${explicitPolicyID ?? IOU.getIOURequestPolicyID(transaction, report)}`); + const {accountID} = useCurrentUserPersonalDetails(); + const policyExpenseReport = policy?.id ? ReportUtils.getPolicyExpenseChat(accountID, policy.id) : undefined; + + const customUnit = PolicyUtils.getPerDiemCustomUnit(policy); + const selectedDestination = transaction?.comment?.customUnit?.customUnitRateID; + + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const {canUseCombinedTrackSubmit} = usePermissions(); + + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = isEmptyObject(policy); + + const {isOffline} = useNetwork(); + const isLoading = !isOffline && isLoadingOnyxValue(policyMetadata); + const shouldShowEmptyState = isEmptyObject(customUnit?.rates); + const shouldShowOfflineView = isEmptyObject(customUnit?.rates) && isOffline; + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const updateDestination = (destination: ListItem & {currency: string}) => { + if (isEmptyObject(customUnit)) { + return; + } + if (selectedDestination !== destination.keyForList) { + if (openedFromStartPage) { + IOU.setMoneyRequestParticipantsFromReport(transactionID, policyExpenseReport); + IOU.setCustomUnitID(transactionID, customUnit.customUnitID); + IOU.setMoneyRequestCategory(transactionID, customUnit?.defaultCategory ?? ''); + } + IOU.setCustomUnitRateID(transactionID, destination.keyForList ?? ''); + IOU.setMoneyRequestCurrency(transactionID, destination.currency); + IOU.clearSubrates(transactionID); + } + + if (backTo) { + navigateBack(); + } else { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME.getRoute(action, iouType, transactionID, policyExpenseReport?.reportID ?? reportID)); + } + }; + + const tabTitles = { + [CONST.IOU.TYPE.REQUEST]: translate('iou.createExpense'), + [CONST.IOU.TYPE.SUBMIT]: canUseCombinedTrackSubmit ? translate('iou.createExpense') : translate('iou.submitExpense'), + [CONST.IOU.TYPE.SEND]: translate('iou.paySomeone', {name: ''}), + [CONST.IOU.TYPE.PAY]: translate('iou.paySomeone', {name: ''}), + [CONST.IOU.TYPE.SPLIT]: translate('iou.createExpense'), + [CONST.IOU.TYPE.TRACK]: canUseCombinedTrackSubmit ? translate('iou.createExpense') : translate('iou.trackExpense'), + [CONST.IOU.TYPE.INVOICE]: translate('workspace.invoices.sendInvoice'), + [CONST.IOU.TYPE.CREATE]: translate('iou.createExpense'), + }; + + return ( + + {isLoading && ( + + )} + {shouldShowOfflineView && {null}} + {shouldShowEmptyState && ( + + + {PolicyUtils.isPolicyAdmin(policy) && !!policy?.areCategoriesEnabled && ( + +